From 3304eed4b390b71f06aff48ca762a0ea028802f5 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 18 Jul 2026 21:19:40 +0300 Subject: [PATCH] Refactor pricing endpoints and enhance support for pricing retrieval - Added a new endpoint `/api/support/pricing` to allow users to retrieve pricing information, making it accessible for role request dialogs. - Introduced the `GetSupportPricing` method to handle pricing queries, ensuring that pricing data is available to non-admin users. - Updated frontend components to integrate the new pricing retrieval functionality, displaying estimated costs based on user-selected configurations. - Removed the `PricingSettingsDto` as it is no longer needed, streamlining the pricing data structure. - Enhanced API documentation to reflect the new endpoint and its usage in the support context. --- .../Endpoints/AdminPricingEndpoints.cs | 1 + .../Endpoints/SupportEndpoints.cs | 11 +++++++ .../Admin/Pricing/GetPricingSettingsQuery.cs | 1 + .../Admin/Pricing/PricingSettingsDto.cs | 17 ----------- .../Pricing/UpdatePricingSettingsCommand.cs | 1 + .../Common/Interfaces/PricingSettingsDto.cs | 20 +++++++++++++ .../GetSupportPricingQuery.cs | 9 ++++++ .../GetSupportPricingQueryHandler.cs | 27 +++++++++++++++++ docs/api-design.md | 1 + docs/domain-model.md | 16 ++++++---- docs/tech-stack.md | 2 +- .../support/CreateRoleRequestDialog.tsx | 30 ++++++++++++++++++- frontend/src/features/support/api.ts | 5 ++++ frontend/src/shared/lib/i18n.ts | 10 +++++++ 14 files changed, 127 insertions(+), 24 deletions(-) delete mode 100644 backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/PricingSettingsDto.cs create mode 100644 backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQuery.cs create mode 100644 backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQueryHandler.cs diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminPricingEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminPricingEndpoints.cs index 1f3567c..6f71523 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminPricingEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminPricingEndpoints.cs @@ -1,5 +1,6 @@ using PnvPanel.Api.Common; using PnvPanel.Application.Admin.Pricing; +using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Infrastructure.Identity; diff --git a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs index f29a7e9..53b1fd9 100644 --- a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs @@ -8,6 +8,7 @@ using PnvPanel.Application.Support.AddComment; using PnvPanel.Application.Support.CreateBugReport; using PnvPanel.Application.Support.CreateRoleRequest; using PnvPanel.Application.Support.GetAttachment; +using PnvPanel.Application.Support.GetSupportPricing; using PnvPanel.Application.Support.GetTicket; using PnvPanel.Application.Support.ListMyTickets; using PnvPanel.Application.Support.ListSelectableRoles; @@ -23,6 +24,7 @@ public static class SupportEndpoints var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization(); group.MapGet("/roles", ListSelectableRoles).Produces>(); + group.MapGet("/pricing", GetSupportPricing).Produces(); group .MapPost("/tickets/bug-reports", CreateBugReport) .DisableAntiforgery() @@ -49,6 +51,15 @@ public static class SupportEndpoints return result.ToHttpResult(); } + private static async Task GetSupportPricing( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetSupportPricingQuery(), cancellationToken); + return result.ToHttpResult(); + } + private static async Task CreateBugReport( [FromForm] string message, IFormFileCollection? files, diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQuery.cs b/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQuery.cs index 89e0a35..27e0709 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQuery.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQuery.cs @@ -1,3 +1,4 @@ +using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs b/backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs deleted file mode 100644 index 0e989a9..0000000 --- a/backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs +++ /dev/null @@ -1,17 +0,0 @@ -using PnvPanel.Domain.Pricing; - -namespace PnvPanel.Application.Admin.Pricing; - -public sealed record PricingSettingsDto( - int? PricePerConfigPerQuarter, - int? PricePerConfigPerHalfYear, - int? PricePerConfigPerYear -) -{ - public static PricingSettingsDto FromDomain(PricingSettings settings) => - new( - settings.PricePerConfigPerQuarter, - settings.PricePerConfigPerHalfYear, - settings.PricePerConfigPerYear - ); -} diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs index 81e36c8..f2905c9 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs @@ -1,3 +1,4 @@ +using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/PricingSettingsDto.cs b/backend/src/PnvPanel.Application/Common/Interfaces/PricingSettingsDto.cs new file mode 100644 index 0000000..d313c9b --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/PricingSettingsDto.cs @@ -0,0 +1,20 @@ +using PnvPanel.Domain.Pricing; + +namespace PnvPanel.Application.Common.Interfaces; + +/// Все поля — цена за конфиг В МЕСЯЦ при соответствующем тарифе оплаты; итог за период = +/// ставка × число месяцев. Используется и Admin (редактирование), и Support (справка при заявке на +/// роль) — см. PricingSettingsDto.FromDomain. +public sealed record PricingSettingsDto( + int? PricePerConfigPerQuarter, + int? PricePerConfigPerHalfYear, + int? PricePerConfigPerYear +) +{ + public static PricingSettingsDto FromDomain(PricingSettings settings) => + new( + settings.PricePerConfigPerQuarter, + settings.PricePerConfigPerHalfYear, + settings.PricePerConfigPerYear + ); +} diff --git a/backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQuery.cs b/backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQuery.cs new file mode 100644 index 0000000..baf29a3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQuery.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Support.GetSupportPricing; + +/// Справочная цена конфига для заявки на роль (не редактирование, только чтение) — в отличие +/// от GetPricingSettingsQuery (Admin/Pricing), доступна любому активированному пользователю. +public sealed record GetSupportPricingQuery : IQuery>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQueryHandler.cs b/backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQueryHandler.cs new file mode 100644 index 0000000..527646a --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/GetSupportPricing/GetSupportPricingQueryHandler.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Support.GetSupportPricing; + +public sealed class GetSupportPricingQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + GetSupportPricingQuery query, + CancellationToken cancellationToken + ) + { + var settings = await dbContext + .PricingSettings.AsNoTracking() + .FirstOrDefaultAsync(cancellationToken); + + // Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка. + return Result.Success( + settings is null + ? new PricingSettingsDto(null, null, null) + : PricingSettingsDto.FromDomain(settings) + ); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index 374b08f..624c14c 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -156,6 +156,7 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро | Метод | Путь | Тело запроса | Тело ответа | | ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- | | GET | `/api/support/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке | +| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена, что и `/api/admin/pricing`, для справки в диалоге заявки на роль | | GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList` (только свои) | | GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) | | POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` | diff --git a/docs/domain-model.md b/docs/domain-model.md index 3d5d032..12ec71a 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -6,7 +6,8 @@ Тарифы `Plan` и лимиты трафика на конфиг (`TrafficLimit`) не реализованы — единственная квота: число активных конфигов на роль (`AppRole.MaxConfigs`). Есть глобальная справочная цена за один -конфиг (`PricingSettings`, видна только админу) — это не биллинг: без статусов оплаты, дат окончания +конфиг (`PricingSettings`; редактирует только `admin`, но справочно видна и активированным пользователям +в заявке на роль) — это не биллинг: без статусов оплаты, дат окончания и интеграций с платёжными системами, см. ниже. ## Диаграмма связей @@ -328,10 +329,15 @@ PricePerConfigPerQuarter × 3` и `PricePerConfigPerYear × 12 ≥ PricePerConfi полугодовая ставка не задана — год сверяется напрямую с кварталом: `PricePerConfigPerYear × 12 ≥ PricePerConfigPerQuarter × 3`). -`GET/PUT /api/admin/pricing` — только `admin` (в отличие от `RoleDto`, цена никогда не попадает в -`GET /api/support/roles`, доступный любому активированному пользователю, — это два независимых DTO). -Сидируется пустой строкой при старте (`IPricingSettingsSeeder`, если таблица пуста) и заново после -полного сброса панели (см. «Полный сброс панели» выше). +`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`, +если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше). ### ActivationRequest — запрос активации Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram. diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 603c51b..1a08dd5 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -75,7 +75,7 @@ | -------------------------- | --------------------------------------------------------------------------------- | | Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) | | Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) | -| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. Есть глобальная справочная цена за конфиг (`PricingSettings`, видна только админу) — без биллинг-логики | +| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. Есть глобальная справочная цена за конфиг (`PricingSettings`, редактирует только `admin`, справочно видна и в заявке на роль) — без биллинг-логики | | i18n | RU + EN (react-i18next) | | Telegram-транспорт | Long polling | | Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) | diff --git a/frontend/src/features/support/CreateRoleRequestDialog.tsx b/frontend/src/features/support/CreateRoleRequestDialog.tsx index fb190e9..39fcac0 100644 --- a/frontend/src/features/support/CreateRoleRequestDialog.tsx +++ b/frontend/src/features/support/CreateRoleRequestDialog.tsx @@ -9,7 +9,7 @@ import { Label } from '@/shared/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { HttpError } from '@/shared/api/client' -import { createRoleRequestTicket, listSelectableRoles } from './api' +import { createRoleRequestTicket, getSupportPricing, listSelectableRoles } from './api' type Mode = 'existing' | 'new' @@ -25,6 +25,7 @@ export function CreateRoleRequestDialog() { const [justification, setJustification] = useState('') const rolesQuery = useQuery({ queryKey: ['selectable-roles'], queryFn: listSelectableRoles, enabled: open }) + const pricingQuery = useQuery({ queryKey: ['support-pricing'], queryFn: getSupportPricing, enabled: open }) const resetForm = () => { setMode('existing') @@ -70,6 +71,23 @@ export function CreateRoleRequestDialog() { ? roleId.length > 0 : newRoleName.trim().length > 0 && newRoleMaxConfigs !== '' && newRoleMaxIpLimit !== '') + // Квота, для которой считаем ориентировочную стоимость: у существующей роли — её maxConfigs, + // у новой — то, что пользователь ввёл (пока не введено или отрицательное кроме -1 — не считаем). + const maxConfigsForPricing = + mode === 'existing' + ? rolesQuery.data?.find((role) => role.id === roleId)?.maxConfigs + : newRoleMaxConfigs !== '' && Number.isFinite(Number(newRoleMaxConfigs)) + ? Number(newRoleMaxConfigs) + : undefined + + // Ставки — цена за конфиг В МЕСЯЦ; итог за период = ставка × месяцев × квота. + const totalPrice = (monthlyRate: number | null | undefined, months: number) => + monthlyRate == null || maxConfigsForPricing == null || maxConfigsForPricing < 0 + ? t('admin.roles.noPrice') + : `${monthlyRate * months * maxConfigsForPricing} ₽` + + const showPricing = maxConfigsForPricing != null && maxConfigsForPricing >= 0 + return ( @@ -146,6 +164,16 @@ export function CreateRoleRequestDialog() { )} + {showPricing && ( +
+

{t('support.pricingTitle')}

+

{t('support.pricingQuarter', { price: totalPrice(pricingQuery.data?.pricePerConfigPerQuarter, 3) })}

+

{t('support.pricingHalfYear', { price: totalPrice(pricingQuery.data?.pricePerConfigPerHalfYear, 6) })}

+

{t('support.pricingYear', { price: totalPrice(pricingQuery.data?.pricePerConfigPerYear, 12) })}

+

{t('support.pricingDisclaimer')}

+
+ )} +