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()`, тело без деталей) |