Enhance user plan management and update related endpoints
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s

- 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:
Leonid Pershin
2026-07-23 22:52:20 +03:00
parent 2c5b730500
commit fad03c2834
152 changed files with 4060 additions and 2240 deletions
+68 -37
View File
@@ -64,7 +64,7 @@ rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 за
| Метод | Путь | Тело запроса | Тело ответа |
| ------ | --------------------------------- | ------------------------------------ | --------------------------------------- |
| GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` |
| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }`**без пагинации**, весь список сразу |
| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], configQuota, planId }`**без пагинации**, весь список сразу |
| POST | `/api/configs` | `{ inboundId, label? }` | `VpnConfigDto` (`200 OK`, не 201) |
| PATCH | `/api/configs/{id}` | `{ label? }` | `VpnConfigDto` |
| POST | `/api/configs/{id}/rotate` | — | `VpnConfigDto` (новый `id` тот же, новый `SubscriptionToken`) |
@@ -80,7 +80,8 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
`connectionString`.
Все `/api/configs/*`, `/api/news`, `/api/apps` без активации → `403` (`Auth.NotActivated`, единая
проверка `RequireActivationBehavior`); создание сверх квоты роли → `409` (`Configs.QuotaExceeded`).
проверка `RequireActivationBehavior`); создание сверх квоты тарифа (`AppUser.ConfigQuota`) → `409`
(`Configs.QuotaExceeded`).
## Apps — каталог приложений
@@ -154,8 +155,8 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
| Метод | Путь | Тело запроса | Тело ответа |
| ----- | -------------------------------------------- | ---------------------- | ------------- |
| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }`. `PaymentRequestDto` теперь несёт `kind` (`Subscription`/`RoleChangeTopUp`) и `period: PaymentPeriod \| null` (`null` для `RoleChangeTopUp` — см. domain-model.md#rolechangetopup). Если у пользователя одновременно активны заявки обоих `Kind`, видна только одна (см. известное ограничение там же) |
| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`Kind.Subscription`; `409 Billing.ActiveRequestExists`, если уже есть активная заявка **того же Kind** — активный `RoleChangeTopUp` не блокирует; `409 Billing.UnlimitedRoleNotSupported` для ролей с `MaxConfigs=-1`; `500 Billing.PricingNotConfigured`, если ставка для периода не задана) |
| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }`. `PaymentRequestDto` теперь несёт `kind` (`Subscription`/`PlanChangeTopUp`) и `period: PaymentPeriod \| null` (`null` для `PlanChangeTopUp` — см. domain-model.md#planchangetopup). Если у пользователя одновременно активны заявки обоих `Kind`, видна только одна (см. известное ограничение там же) |
| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`Kind.Subscription`; `409 Billing.ActiveRequestExists`, если уже есть активная заявка **того же Kind** — активный `PlanChangeTopUp` не блокирует; `409 Billing.UnlimitedRoleNotSupported` при `ConfigQuota=-1` (безлимит — только у `admin`); `500 Billing.PricingNotConfigured`, если ставка для периода не задана) |
| POST | `/api/billing/requests/{id}/cancel` | — | `204 No Content` (только из `AwaitingPayment`) |
| POST | `/api/billing/requests/{id}/mark-paid` | — | `204 No Content` (`AwaitingPayment → AwaitingConfirmation`, уведомляет админов в Telegram) |
| POST | `/api/billing/requests/{id}/send-requisites` | — | `204 No Content` (дублирует реквизиты в свой Telegram; `409 Telegram.NotLinked`, если Telegram не привязан) |
@@ -168,28 +169,24 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
| Метод | Путь | Тело запроса | Тело ответа |
| ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/support/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке |
| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена (включая скидочную лесенку `discountTiers`), что и `/api/admin/pricing`, для справки в диалоге заявки на роль |
| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена (включая скидочную лесенку `discountTiers`), что и `/api/admin/pricing`, для справки на странице смены тарифа (`/plan`, см. Plans ниже) |
| GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList<TicketSummaryDto>` (только свои) |
| GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) |
| POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` |
| POST | `/api/support/tickets/role-requests` | `{ existingRoleId? \| (newRoleName, newRoleMaxConfigs, newRoleMaxIpLimit), justification }` | `TicketDetailDto` |
| POST | `/api/support/tickets/extension-requests` | `{ requestedDays, justification }` | `TicketDetailDto` (`403 Billing.NotEnabled`, если роль не billing; `409 Support.ExtensionRequestAlreadyPending`) |
| POST | `/api/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` |
| POST | `/api/support/tickets/{id}/reopen` | — | `204 No Content` (только владелец, только из `Resolved`) |
| GET | `/api/support/attachments/{id}` | — | бинарный поток с `Content-Type` вложения |
`TicketSummaryDto`: `{ id, userId, userName, type, status, createdAt, lastActivityAt }` — один DTO для
своего и админского списков. `TicketDetailDto` добавляет `requestedRoleId, requestedRoleName,
proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, requestedDays, comments: TicketCommentDto[]`.
`TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }`.
своего и админского списков. `TicketDetailDto` добавляет `requestedDays, comments:
TicketCommentDto[]`. `TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments:
TicketAttachmentDto[] }`.
Ровно одна из двух заявок на роль: либо `existingRoleId` (роль `admin` запрещена — `403
Support.CannotRequestAdminRole`), либо все три поля новой роли. Заявка при существующем открытом
запросе на роль → `409 Support.RoleRequestAlreadyPending`; аналогично для продления →
`409 Support.ExtensionRequestAlreadyPending`. `POST …/comments` на `Closed`-тикете →
`409 Support.TicketClosed`. Вложения отдаются не статикой — `<img src>` не может передать
`Authorization`-заголовок, фронт качает их как `Blob` через `fetch` и рендерит `Object URL`.
Заявка при существующем открытом запросе на продление → `409 Support.ExtensionRequestAlreadyPending`.
`POST …/comments` на `Closed`-тикете → `409 Support.TicketClosed`. Вложения отдаются не статикой —
`<img src>` не может передать `Authorization`-заголовок, фронт качает их как `Blob` через `fetch`
и рендерит `Object URL`.
## Admin — Support
@@ -201,25 +198,19 @@ Support.CannotRequestAdminRole`), либо все три поля новой р
| GET | `/api/admin/support/tickets` | query: `type?, status?, page, pageSize` | `PagedList<TicketSummaryDto>` (все пользователи) |
| GET | `/api/admin/support/tickets/{id}` | — | `TicketDetailDto` |
| POST | `/api/admin/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` |
| POST | `/api/admin/support/tickets/{id}/resolve` | — | `204 No Content` (любой тип, только из `Open`) |
| POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (любой тип, финал) |
| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль сразу; если новая роль дороже старой и у пользователя активен `BillingPaidUntil` — дополнительно создаёт `PaymentRequest(Kind.RoleChangeTopUp)` на разницу в цене, см. domain-model.md#rolechangetopup) |
| POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) |
| POST | `/api/admin/support/tickets/{id}/resolve` | — | `204 No Content` (только `BugReport`, только из `Open`) |
| POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (только `BugReport`, финал) |
| POST | `/api/admin/support/tickets/{id}/approve-extension` | — | `204 No Content` (только `ExtensionRequest`/`Open`; продлевает `BillingPaidUntil` на `RequestedDays`) |
| POST | `/api/admin/support/tickets/{id}/reject-extension` | `{ reason? }` | `204 No Content` (только `ExtensionRequest`; `reason` уходит комментарием) |
Обработать **собственный** тикет админу можно (в т.ч. одобрить свою же заявку на роль) — resolve/close/
reject/approve владением тикета не ограничены. Единственное реальное ограничение — `approve` вернёт
`409 Roles.CannotRemoveLastAdmin` через `ChangeUserRoleAsync`, если заявка (своя или чужая) снимает
`admin` с последнего администратора в системе.
Обработать **собственный** тикет админу можно — resolve/close/approve-extension/reject-extension
владением тикета не ограничены (единственный админ иначе не смог бы закрыть свой же тикет).
`approve`/`reject` — единственный способ решить заявку на роль (нельзя одобрить через `resolve`).
При одобрении: если заявка на существующую роль — сразу `ChangeUserRoleCommand`-эквивалент; если на
новую — сперва создаётся `AppRole` (`IRoleService.CreateRoleAsync`), затем назначается. То же самое
администратор может сделать **из Telegram, не заходя на сайт** — инлайн-кнопки на уведомлении о
заявке (см. [telegram-bot.md](telegram-bot.md)); для баг-репортов в Telegram только кнопка-ссылка
на `/admin/support?ticket={id}` — переписка и вложения только на сайте (отдельного роута на конкретный
тикет нет, `?ticket=` открывает диалог поверх списка).
`approve-extension`/`reject-extension` — единственный способ решить заявку на продление (нельзя
одобрить через `resolve`). То же самое администратор может сделать **из Telegram, не заходя на
сайт** — инлайн-кнопки на уведомлении о заявке (см. [telegram-bot.md](telegram-bot.md)); для
баг-репортов в Telegram только кнопка-ссылка на `/admin/support?ticket={id}` — переписка и вложения
только на сайте (отдельного роута на конкретный тикет нет, `?ticket=` открывает диалог поверх списка).
## Admin — Maintenance
@@ -262,16 +253,56 @@ reject/approve владением тикета не ограничены. Еди
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit, billingEnabled }` | `RoleDto` (`400`, если `billingEnabled=true` для `name="admin"`) |
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, billingEnabled }` | `RoleDto` (то же ограничение на `admin`; включение `billingEnabled` ретроактивно выдаёт грейс-период уже назначенным пользователям без `PaidUntil`) |
| POST | `/api/admin/roles` | admin | `{ name, maxIpLimit, billingEnabled }` | `RoleDto` (`400`, если `billingEnabled=true` для `name="admin"`) |
| PUT | `/api/admin/roles/{id}` | admin | `{ maxIpLimit, billingEnabled }` | `RoleDto` (то же ограничение на `admin`; включение `billingEnabled` ретроактивно выдаёт грейс-период уже назначенным пользователям без `PaidUntil`) |
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) |
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц** + скидочная лесенка `discountTiers: { minConfigs, discountPercent }[]`, одна на весь сервис — не per-роль) |
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ; см. domain-model.md#approle — переход на/с `admin` автоматически выдаёт/сбрасывает безлимитную квоту) |
| PATCH | `/api/admin/users/{id}/plan` | admin | `{ planId? \| customConfigCount? }` | `204 No Content` — прямой оверрайд квоты конфигов пользователя (ровно одно из полей); в отличие от `POST /api/plans/change` — без пикера конфигов на понижение (грандфазеринг) и без доплаты; `customConfigCount = -1` (безлимит) — `409 Plans.UnlimitedOnlyForAdmin`, если текущая роль цели не `admin` |
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц** + скидочная лесенка `discountTiers: { minConfigs, discountPercent }[]`, одна на весь сервис — не per-роль/тариф) |
| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear?, discountTiers: { minConfigs, discountPercent }[] }` | `PricingSettingsDto` (`400`, если итог более длинного тарифа дешевле итога более короткого, либо `discountTiers` не уникальны/не прогрессивны — см. domain-model.md#pricingdiscounttier). `discountTiers` при сохранении полностью заменяет прежний набор |
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
approve/reject над `ActivationRequest`.
## Plans (пользователь) — самостоятельная смена тарифа
Группа `/api/plans`, `RequireAuthorization()` + `IRequiresActivation`. Полная модель — см.
[domain-model.md](domain-model.md#plan--тариф-самообслуживание-квота-конфигов).
| Метод | Путь | Тело запроса | Тело ответа |
| ----- | -------------------- | -------------------------------------------------------------------- | ------------- |
| GET | `/api/plans` | — | `PlanDto[]` (`{ id, name, configCount }`, только `isEnabled == true`, сортировка `sortOrder asc`) |
| GET | `/api/plans/status` | — | `MyPlanStatusDto { configQuota, planId, activeConfigCount, billingEnabled, billingPaidUntil }` |
| POST | `/api/plans/change` | `{ planId? \| customConfigCount?, configIdsToRevoke: Guid[] }` | `ChangePlanResultDto { configQuota, planId, topUpAmount: int? }` |
`POST /api/plans/change` — ровно одно из `planId`/`customConfigCount` (иначе `400`); квота меняется
**сразу**, без подтверждения админом. Если новое количество меньше текущего числа конфигов, всё ещё
занимающих квоту (`Active` **и** `Expired` — приостановленные за неуплату тоже считаются, иначе их
можно было бы молча вернуть сверх новой квоты следующей оплатой) — `configIdsToRevoke` обязателен и
должен содержать ровно (это число − новое количество) id, иначе `400 Plans.MustSelectConfigsToRevoke`
(фронт по этой ошибке показывает пикер конфигов, включая приостановленные). Если у роли пользователя
включён биллинг, увеличение тарифа при активном
`BillingPaidUntil` создаёт `PaymentRequest(Kind.PlanChangeTopUp)` на разницу в цене —
`topUpAmount` в ответе ненулевой, см. [domain-model.md](domain-model.md#planchangetopup).
`customConfigCount` ограничен `[Plans__MinCustomConfigCount, Plans__MaxCustomConfigCount]`
(по умолчанию `[3, 50]`) — иначе `400`; `-1` (безлимит) недоступен через самообслуживание в
принципе (вне допустимого диапазона). Тариф выключен/не найден → `404 Plans.NotFound` /
`400 Plans.Disabled`.
## Admin — Plans
Группа `/api/admin/plans`, `RequireAuthorization(RoleNames.Admin)`. Точное зеркало `/api/admin/apps`.
| Метод | Путь | Тело запроса | Тело ответа |
| ------ | ------------------------- | ----------------------------------------------------- | ------------- |
| GET | `/api/admin/plans` | — | `AdminPlanDto[]` (вкл. выключенные, `{ id, name, configCount, sortOrder, isEnabled }`) |
| POST | `/api/admin/plans` | `{ name, configCount, sortOrder }` | `AdminPlanDto` |
| PUT | `/api/admin/plans/{id}` | `{ name, configCount, sortOrder, isEnabled }` | `AdminPlanDto` |
| DELETE | `/api/admin/plans/{id}` | — | `204 No Content` |
`ConfigCount` — только положительное число (каталожные тарифы не поддерживают безлимит; безлимит
доступен исключительно роли `admin` через `AppUser.ConfigQuota=-1`, вне каталога).
## Admin — Billing
| Метод | Путь | Роль | Тело запроса | Тело ответа |
@@ -279,7 +310,7 @@ approve/reject над `ActivationRequest`.
| GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }` |
| PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | `BillingSettingsDto` |
| GET | `/api/admin/billing/requests` | admin | query: `status?, kind?, search?, page=1, pageSize=20` (`search` — по имени пользователя, резолвится до пагинации) | `PagedList<AdminPaymentRequestDto>` (включает `userName`, `kind`, `period: PaymentPeriod \| null`) |
| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (для `Kind.Subscription` продлевает `BillingPaidUntil` и возвращает приостановленные конфиги в `Active`; для `Kind.RoleChangeTopUp` — только помечает `Confirmed`, `BillingPaidUntil` не трогает, см. domain-model.md#rolechangetopup) |
| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (для `Kind.Subscription` продлевает `BillingPaidUntil` и возвращает приостановленные конфиги в `Active`; для `Kind.PlanChangeTopUp` — только помечает `Confirmed`, `BillingPaidUntil` не трогает, см. domain-model.md#planchangetopup) |
| POST | `/api/admin/billing/requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
| POST | `/api/admin/billing/gift` | admin | `{ userId, days }` | `204 No Content` (продлевает `BillingPaidUntil` на `days` от `max(текущий, сейчас)`, возвращает приостановленные конфиги, шлёт Telegram-уведомление пользователю; `403 Billing.NotEnabled`, если роль пользователя не billing) |
@@ -384,7 +415,7 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи
| 401 | Нет/просрочен/невалиден access-токен |
| 403 | Нет прав по роли, либо `Auth.NotActivated`, либо `Configs.BillingRequired` (просрочена оплата) |
| 404 | Ресурс не найден |
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed`, `Billing.ActiveRequestExists`, `Billing.UnlimitedRoleNotSupported`, `Telegram.NotLinked` |
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.ExtensionRequestAlreadyPending`, `Support.TicketClosed`, `Billing.ActiveRequestExists`, `Billing.UnlimitedRoleNotSupported`, `Plans.MustSelectConfigsToRevoke`, `Plans.UnlimitedOnlyForAdmin`, `Telegram.NotLinked` |
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
+1 -1
View File
@@ -146,7 +146,7 @@ POST /api/configs
→ CreateVpnConfigCommandHandler
· проверяет роль инбаунда (доменная проверка)
· SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания
· пересчитывает текущее число активных конфигов и сверяет с AppRole.MaxConfigs
· пересчитывает текущее число активных конфигов и сверяет с AppUser.ConfigQuota
· IXuiPanelGateway.AddClientAsync(node, inbound, ...) // 3x-ui, получает ClientExternalId
· VpnConfig.Create(...) + AssignRemoteClient(id), сохраняет через IAppDbContext
· при сбое SaveChanges после успешного AddClientAsync — компенсация (RemoveClientAsync)
+205 -142
View File
@@ -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 владельцу |
+4 -2
View File
@@ -16,8 +16,10 @@
(`AsNoTracking` + `Select`).
- **БД**: PostgreSQL.
- **Auth**: ASP.NET Core Identity + JWT (access, короткий TTL) + refresh (httpOnly cookie, ротация).
- **RBAC**: динамические роли с квотой (`AppRole.MaxConfigs`). Доступ к инбаундам — по ролям
(`Inbound.AllowedRoles`). Квота на число конфигов — на роли, а не на тариф.
- **RBAC**: динамические роли (`AppRole` — устройства `MaxIpLimit`, доступ к инбаундам
`Inbound.AllowedRoles`, флаг `BillingEnabled`). Квота на число конфигов — на пользователе
(`AppUser.ConfigQuota`), задаётся самообслуживаемым тарифом (`Plan`), не ролью; безлимит (`-1`)
зарезервирован за ролью `admin`.
- **Активация пользователей**: `AppUser.IsActivated` + `ActivationRequest` (с комментарием).
Неактивированный не создаёт конфиги; решение принимает админ на сайте или в Telegram — одними и
теми же CQRS-командами.
+12 -27
View File
@@ -33,12 +33,12 @@ Telegram-бот — **второй канал доставки** (presentation-
Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если задан `Telegram__PublicSiteUrl`
(пусто — кнопки нет). Слэш-команды `/configs`/`/unlink` продолжают работать как раньше — кнопки лишь
вызывают те же обработчики через callback (`menu:configs`/`menu:unlink`/`menu:back`).
8. **Админ: обработка заявок на роль поддержки** — при новой заявке (`SupportTicket.Type ==
RoleRequest`) админ получает сообщение с описанием (существующая роль либо параметры новой) и
обоснованием, жмёт «✅ Одобрить / ❌ Отклонить» **прямо в Telegram, без захода на сайт** — одобрение
создаёт роль (если новая) и назначает её пользователю той же командой, что и на сайте. Баг-репорты/
предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий (переписка и
вложения удобнее там).
8. **Админ: обработка заявок на продление поддержки** — при новой заявке
(`SupportTicket.Type == ExtensionRequest`, только для billing-ролей) админ получает сообщение с
числом запрошенных дней и обоснованием, жмёт «✅ Одобрить / ❌ Отклонить» **прямо в Telegram, без
захода на сайт** — одобрение продлевает `BillingPaidUntil` той же командой, что и на сайте.
Баг-репорты/предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий
(переписка и вложения удобнее там).
**Не реализовано:**
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
@@ -49,6 +49,7 @@ Telegram-бот — **второй канал доставки** (presentation-
просмотр списка и показ существующей ссылки по кнопке).
- Баг-репорты/предложения тикетов поддержки **не решаются из бота** (только уведомление-ссылка) —
ответы, вложения, resolve/close только на сайте.
- Смена тарифа (`ChangePlanCommand`) — только на сайте (`/plan`), в боте не решается.
## Размещение в архитектуре
@@ -182,23 +183,8 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
(`InlineKeyboardButton.WithUrl`, не callback) — тап открывает страницу тикета в браузере.
3. Дальше — только на сайте: переписка, вложения, resolve/close.
**Заявка на роль** — полностью решается в Telegram:
1. `CreateRoleRequestTicketCommandHandler` вызывает
`ITelegramNotifier.NotifyAdminsRoleRequestCreatedAsync(ticketId, userName, roleDescription, justification, ct)`
— `roleDescription` уже готовая строка (имя существующей роли либо «новая роль «X» (конфигов: N, IP: M)»).
2. Сообщение с кнопками **«✅ Одобрить» / «❌ Отклонить»** (callback `rrq:approve:{id}`/`rrq:reject:{id}`
— тот же 3-частный формат `prefix:action:guid`, что и `act:*` для активации).
3. Нажатие → проверка прав (`TrySetAdminCurrentUserAsync`, тот же, что для активации) →
`ApproveRoleRequestCommand`/`RejectRoleRequestCommand` (те же команды, что дёргает
`POST /api/admin/support/tickets/{id}/approve|reject` на сайте). При одобрении — если роль новая,
сперва создаётся `AppRole`, затем в любом случае назначается пользователю; тикет переходит в
`Resolved`/`Closed`. Нажавшему админу — короткое подтверждение, исходное сообщение редактируется
(дописывается статус), как и у `act:*`.
4. Пользователю (если Telegram привязан) — DM «✅ Ваша заявка на роль одобрена.» / «❌ Ваша заявка на
роль отклонена.».
**Заявка на продление** (`ExtensionRequest`, только для billing-ролей) — тот же паттерн, что и заявка
на роль, инлайн-кнопки с префиксом `erq:`:
**Заявка на продление** (`ExtensionRequest`, только для billing-ролей) — полностью решается в
Telegram, инлайн-кнопки с префиксом `erq:`:
1. `CreateExtensionRequestTicketCommandHandler` вызывает
`ITelegramNotifier.NotifyAdminsExtensionRequestCreatedAsync(ticketId, userName, requestedDays, justification, ct)`.
2. Кнопки **«✅ Одобрить» / «❌ Отклонить»** (callback `erq:approve:{id}`/`erq:reject:{id}`).
@@ -213,7 +199,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
Только для ролей с `AppRole.BillingEnabled` (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)).
Заявка (`PaymentRequest`) заводится и решается частично на сайте, частично в Telegram — симметрично
заявке на роль:
заявке на продление:
1. Пользователь создаёт заявку и жмёт «Я оплатил» на сайте (`/billing`) — бот в это не вовлечён,
кроме опциональной кнопки «Отправить реквизиты в Telegram» (DM самому себе для удобства, статус
@@ -221,11 +207,11 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
2. `MarkPaymentSentCommandHandler` вызывает
`ITelegramNotifier.NotifyAdminsPaymentRequestedAsync(requestId, userName, period, amount, ct)`.
3. Сообщение с кнопками **«✅ Подтвердить» / «❌ Отклонить»** (callback `pay:approve:{id}`/`pay:reject:{id}`
— тот же 3-частный формат, что и `rrq:*`/`act:*`).
— тот же 3-частный формат, что и `erq:*`/`act:*`).
4. Нажатие → проверка прав (`TrySetAdminCurrentUserAsync`) → `ConfirmPaymentRequestCommand`/
`RejectPaymentRequestCommand` (те же команды, что дёргает `POST /api/admin/billing/requests/{id}/confirm|reject`
на сайте). Подтверждение продлевает `AppUser.BillingPaidUntil` и возвращает приостановленные
конфиги в `Active`. Исходное сообщение редактируется (дописывается статус), как у `rrq:*`/`act:*`.
конфиги в `Active`. Исходное сообщение редактируется (дописывается статус), как у `erq:*`/`act:*`.
5. Пользователю (если Telegram привязан) — DM «✅ Оплата подтверждена. Доступ продлён до {дата}.» /
«❌ Заявка на оплату отклонена.».
@@ -261,7 +247,6 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) |
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
| «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env |
| «✅ Одобрить»/«❌ Отклонить» (`erq:*`) | (admin) решение по заявке на продление — продлевает `BillingPaidUntil` | админ по env |
| «✅ Подтвердить»/«❌ Отклонить» (`pay:*`) | (admin) решение по заявке на оплату — продлевает `BillingPaidUntil` | админ по env |
| «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env |