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:
+68
-37
@@ -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()`, тело без деталей) |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user