diff --git a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs index f36f2c0..f14bf72 100644 --- a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs @@ -53,7 +53,13 @@ public static class RoleEndpoints ) { var result = await sender.Send( - new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit), + new UpdateRoleCommand( + id, + body.MaxConfigs, + body.MaxIpLimit, + body.PricePerConfigPerQuarter, + body.PricePerConfigPerYear + ), cancellationToken ); return result.ToHttpResult(); @@ -84,6 +90,11 @@ public static class RoleEndpoints } } -public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit); +public sealed record UpdateRoleBody( + int MaxConfigs, + int MaxIpLimit, + int? PricePerConfigPerQuarter, + int? PricePerConfigPerYear +); public sealed record ChangeUserRoleBody(Guid RoleId); diff --git a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs index f29a7e9..a74f894 100644 --- a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs @@ -22,7 +22,7 @@ public static class SupportEndpoints { var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization(); - group.MapGet("/roles", ListSelectableRoles).Produces>(); + group.MapGet("/roles", ListSelectableRoles).Produces>(); group .MapPost("/tickets/bug-reports", CreateBugReport) .DisableAntiforgery() diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs index 3fec0d8..30ada39 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs @@ -4,5 +4,10 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Admin.Roles; -public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit) - : ICommand>; +public sealed record CreateRoleCommand( + string Name, + int MaxConfigs, + int MaxIpLimit, + int? PricePerConfigPerQuarter, + int? PricePerConfigPerYear +) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs index 8d6efff..33daa84 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs @@ -15,6 +15,8 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService) command.Name, command.MaxConfigs, command.MaxIpLimit, + command.PricePerConfigPerQuarter, + command.PricePerConfigPerYear, cancellationToken ); } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs index 5c20099..27d1b09 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs @@ -10,5 +10,12 @@ public sealed class CreateRoleCommandValidator : AbstractValidator x.MaxConfigs).GreaterThanOrEqualTo(-1); RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1); + + RuleFor(x => x.PricePerConfigPerQuarter!.Value) + .GreaterThanOrEqualTo(0) + .When(x => x.PricePerConfigPerQuarter.HasValue); + RuleFor(x => x.PricePerConfigPerYear!.Value) + .GreaterThanOrEqualTo(0) + .When(x => x.PricePerConfigPerYear.HasValue); } } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs index de07ab2..53f338d 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs @@ -4,5 +4,10 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Admin.Roles; -public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit) - : ICommand>; +public sealed record UpdateRoleCommand( + Guid RoleId, + int MaxConfigs, + int MaxIpLimit, + int? PricePerConfigPerQuarter, + int? PricePerConfigPerYear +) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs index a2dc00b..1b37a34 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs @@ -15,6 +15,8 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService) command.RoleId, command.MaxConfigs, command.MaxIpLimit, + command.PricePerConfigPerQuarter, + command.PricePerConfigPerYear, cancellationToken ); } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs index f436e57..fe3ec6f 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs @@ -8,5 +8,12 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator x.MaxConfigs).GreaterThanOrEqualTo(-1); RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1); + + RuleFor(x => x.PricePerConfigPerQuarter!.Value) + .GreaterThanOrEqualTo(0) + .When(x => x.PricePerConfigPerQuarter.HasValue); + RuleFor(x => x.PricePerConfigPerYear!.Value) + .GreaterThanOrEqualTo(0) + .When(x => x.PricePerConfigPerYear.HasValue); } } diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs index ef5d00f..4a3913a 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs @@ -49,6 +49,8 @@ public sealed class ApproveRoleRequestCommandHandler( ticket.ProposedRoleName!, ticket.ProposedMaxConfigs!.Value, ticket.ProposedMaxIpLimit!.Value, + pricePerConfigPerQuarter: null, + pricePerConfigPerYear: null, cancellationToken ); if (!createResult.IsSuccess) diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs index 272608b..e29414a 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs @@ -2,7 +2,15 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Common.Interfaces; -public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit, bool IsSystem); +public sealed record RoleDto( + Guid Id, + string Name, + int MaxConfigs, + int MaxIpLimit, + bool IsSystem, + int? PricePerConfigPerQuarter = null, + int? PricePerConfigPerYear = null +); public interface IRoleService { @@ -10,6 +18,8 @@ public interface IRoleService string name, int maxConfigs, int maxIpLimit, + int? pricePerConfigPerQuarter, + int? pricePerConfigPerYear, CancellationToken cancellationToken ); @@ -17,6 +27,8 @@ public interface IRoleService Guid roleId, int maxConfigs, int maxIpLimit, + int? pricePerConfigPerQuarter, + int? pricePerConfigPerYear, CancellationToken cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs index 4e2c212..bea5f6d 100644 --- a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs +++ b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs @@ -1,4 +1,3 @@ -using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; @@ -7,5 +6,5 @@ namespace PnvPanel.Application.Support.ListSelectableRoles; /// Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery /// (Admin/Roles), доступен любому активированному пользователю. public sealed record ListSelectableRolesQuery - : IQuery>>, + : IQuery>>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs index e910896..fe9c242 100644 --- a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs @@ -9,29 +9,31 @@ public sealed class ListSelectableRolesQueryHandler( IRoleService roleService, IIdentityService identityService, ICurrentUser currentUser -) : IQueryHandler>> +) : IQueryHandler>> { // Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в // CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure). private const string AdminRoleName = "admin"; - public async Task>> Handle( + public async Task>> Handle( ListSelectableRolesQuery query, CancellationToken cancellationToken ) { if (currentUser.UserId is not { } userId) - return Result.Failure>(AuthErrors.Unauthorized); + return Result.Failure>(AuthErrors.Unauthorized); var profile = await identityService.GetProfileAsync(userId, cancellationToken); var roles = await roleService.ListRolesAsync(cancellationToken); // Без admin (нельзя запросить) и без текущей роли пользователя (уже есть — нечего запрашивать). + // Цена (RoleDto.PricePerConfigPer*) намеренно не пробрасывается — видна только админу. var selectable = roles .Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase)) .Where(r => profile is null || r.Id != profile.RoleId) + .Select(r => new SelectableRoleDto(r.Id, r.Name, r.MaxConfigs, r.MaxIpLimit)) .ToList(); - return Result.Success>(selectable); + return Result.Success>(selectable); } } diff --git a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/SelectableRoleDto.cs b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/SelectableRoleDto.cs new file mode 100644 index 0000000..ba91c82 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/SelectableRoleDto.cs @@ -0,0 +1,5 @@ +namespace PnvPanel.Application.Support.ListSelectableRoles; + +/// Роль для выбора в заявке на роль. Без справочной цены (RoleDto.PricePerConfigPer*) — +/// она видна только админу, этот эндпоинт доступен любому активированному пользователю. +public sealed record SelectableRoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit); diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs index 2515534..d1b4217 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs @@ -18,6 +18,14 @@ public class AppRole : IdentityRole public bool IsSystem { get; set; } + /// Справочная цена за конфиг за минимальный период оплаты (3 месяца), в рублях. Только для + /// отображения админу — без биллинга/статусов оплаты. У роли `admin` всегда null. + public int? PricePerConfigPerQuarter { get; set; } + + /// Справочная цена за конфиг за год, в рублях. Задаётся независимо от квартальной цены + /// (не производная — позволяет админу задать скидку за годовую оплату). У роли `admin` всегда null. + public int? PricePerConfigPerYear { get; set; } + public AppRole() { } public AppRole(string name) diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs index fb5e7f4..c55381f 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs @@ -16,6 +16,8 @@ internal sealed class RoleService( string name, int maxConfigs, int maxIpLimit, + int? pricePerConfigPerQuarter, + int? pricePerConfigPerYear, CancellationToken cancellationToken ) { @@ -27,6 +29,8 @@ internal sealed class RoleService( MaxConfigs = maxConfigs, MaxIpLimit = maxIpLimit, IsSystem = false, + PricePerConfigPerQuarter = pricePerConfigPerQuarter, + PricePerConfigPerYear = pricePerConfigPerYear, }; var result = await roleManager.CreateAsync(role); if (!result.Succeeded) @@ -46,6 +50,8 @@ internal sealed class RoleService( Guid roleId, int maxConfigs, int maxIpLimit, + int? pricePerConfigPerQuarter, + int? pricePerConfigPerYear, CancellationToken cancellationToken ) { @@ -53,8 +59,13 @@ internal sealed class RoleService( if (role is null) return Result.Failure(RoleErrors.NotFound); + var isAdmin = role.Name!.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase); + role.MaxConfigs = maxConfigs; role.MaxIpLimit = maxIpLimit; + // Роль admin оплаты не имеет — цена не задаётся, даже если пришла в запросе. + role.PricePerConfigPerQuarter = isAdmin ? null : pricePerConfigPerQuarter; + role.PricePerConfigPerYear = isAdmin ? null : pricePerConfigPerYear; await roleManager.UpdateAsync(role); return Result.Success(ToDto(role)); @@ -81,7 +92,15 @@ internal sealed class RoleService( { return await roleManager .Roles.OrderBy(r => r.Name) - .Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.MaxIpLimit, r.IsSystem)) + .Select(r => new RoleDto( + r.Id, + r.Name!, + r.MaxConfigs, + r.MaxIpLimit, + r.IsSystem, + r.PricePerConfigPerQuarter, + r.PricePerConfigPerYear + )) .ToListAsync(cancellationToken); } @@ -118,5 +137,13 @@ internal sealed class RoleService( } private static RoleDto ToDto(AppRole role) => - new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem); + new( + role.Id, + role.Name!, + role.MaxConfigs, + role.MaxIpLimit, + role.IsSystem, + role.PricePerConfigPerQuarter, + role.PricePerConfigPerYear + ); } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718154538_AddRolePricing.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718154538_AddRolePricing.Designer.cs new file mode 100644 index 0000000..2b2cf96 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718154538_AddRolePricing.Designer.cs @@ -0,0 +1,944 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260718154538_AddRolePricing")] + partial class AddRolePricing + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718154538_AddRolePricing.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718154538_AddRolePricing.cs new file mode 100644 index 0000000..a4a8685 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718154538_AddRolePricing.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRolePricing : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PricePerConfigPerQuarter", + table: "AspNetRoles", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "PricePerConfigPerYear", + table: "AspNetRoles", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PricePerConfigPerQuarter", + table: "AspNetRoles"); + + migrationBuilder.DropColumn( + name: "PricePerConfigPerYear", + table: "AspNetRoles"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 90b11f0..86167a0 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -708,6 +708,12 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + b.HasKey("Id"); b.HasIndex("NormalizedName") diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs index 2715c5d..8a65a5e 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs @@ -27,7 +27,7 @@ public class ApproveRoleRequestCommandHandlerTests var newRoleId = Guid.NewGuid(); _roleService - .CreateRoleAsync("premium", 10, 5, Arg.Any()) + .CreateRoleAsync("premium", 10, 5, null, null, Arg.Any()) .Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false))); _roleService .ChangeUserRoleAsync(userId, newRoleId, Arg.Any()) @@ -51,7 +51,7 @@ public class ApproveRoleRequestCommandHandlerTests Assert.Equal(TicketStatus.Resolved, ticket.Status); await _roleService .Received(1) - .CreateRoleAsync("premium", 10, 5, Arg.Any()); + .CreateRoleAsync("premium", 10, 5, null, null, Arg.Any()); await _roleService .Received(1) .ChangeUserRoleAsync(userId, newRoleId, Arg.Any()); @@ -100,6 +100,8 @@ public class ApproveRoleRequestCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any() ); } diff --git a/docs/api-design.md b/docs/api-design.md index 452b465..6078959 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -244,8 +244,8 @@ 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 }` | `RoleDto` | -| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` | +| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit, pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `RoleDto` | +| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `RoleDto` (для `admin` цена игнорируется, остаётся `null`) | | 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`, новая роль другая, и это единственный админ) | diff --git a/docs/domain-model.md b/docs/domain-model.md index 98fa046..4cad0e0 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -5,7 +5,9 @@ `IdentityRole`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`. Тарифы `Plan` и лимиты трафика на конфиг (`TrafficLimit`) не реализованы — единственная квота: -число активных конфигов на роль (`AppRole.MaxConfigs`). +число активных конфигов на роль (`AppRole.MaxConfigs`). У роли есть справочная цена за конфиг +(`AppRole.PricePerConfigPer*`, видна только админу) — это не биллинг: без статусов оплаты, дат +окончания и интеграций с платёжными системами, см. ниже. ## Диаграмма связей @@ -278,6 +280,16 @@ UI **настойчиво напоминает** привязать его (ед | `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) | | `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) | | `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать | +| `PricePerConfigPerQuarter` | `int?` | Справочная цена за конфиг за минимальный период оплаты (3 месяца), руб. Видна только админу (Admin-only эндпоинты); у `admin` всегда `null` | +| `PricePerConfigPerYear` | `int?` | Справочная цена за конфиг за год, руб. Задаётся независимо от квартальной (не производная — позволяет скидку); у `admin` всегда `null` | + +Итоговая цена роли для админа — `Price × MaxConfigs` (напр. `user` с `MaxConfigs=3`, 200₽/конфиг/3мес → +600₽/3мес; 400₽/конфиг/год → 1200₽/год); считается на фронте, не хранится отдельно. Чисто +информационное поле: без биллинга, статусов оплаты, дат окончания подписки, интеграций с платёжными +системами. `IRoleService.UpdateRoleAsync` игнорирует цену при обновлении роли `admin` (остаётся +`null`), даже если она передана в запросе. `GET /api/support/roles` (список ролей для заявки на роль, +доступен любому активированному пользователю) отдаёт отдельный `SelectableRoleDto` без цены — она не +протекает за пределы `/api/admin/*`. Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`, по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2). diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 3e851e0..7b246dd 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 на томе) | -| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока | +| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. У роли есть справочная цена за конфиг (`AppRole.PricePerConfigPer*`, видна только админу) — без биллинг-логики | | i18n | RU + EN (react-i18next) | | Telegram-транспорт | Long polling | | Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) | diff --git a/frontend/src/features/admin/roles/RoleFormDialog.tsx b/frontend/src/features/admin/roles/RoleFormDialog.tsx index 1dc838a..e25ad0b 100644 --- a/frontend/src/features/admin/roles/RoleFormDialog.tsx +++ b/frontend/src/features/admin/roles/RoleFormDialog.tsx @@ -24,17 +24,38 @@ export function RoleFormDialog({ const [name, setName] = useState(role?.name ?? '') const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3)) const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2)) + const [pricePerConfigPerQuarter, setPricePerConfigPerQuarter] = useState( + role?.pricePerConfigPerQuarter != null ? String(role.pricePerConfigPerQuarter) : '', + ) + const [pricePerConfigPerYear, setPricePerConfigPerYear] = useState( + role?.pricePerConfigPerYear != null ? String(role.pricePerConfigPerYear) : '', + ) const [internalOpen, setInternalOpen] = useState(false) const isControlled = open !== undefined const dialogOpen = isControlled ? open : internalOpen const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen + // Роль admin оплаты не имеет — поля цены для неё не показываются и не отправляются. + const isAdmin = role?.name === 'admin' + const mutation = useMutation({ mutationFn: () => role - ? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit)) - : createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit)), + ? updateRole( + role.id, + Number(maxConfigs), + Number(maxIpLimit), + pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter), + pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear), + ) + : createRole( + name.trim(), + Number(maxConfigs), + Number(maxIpLimit), + pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter), + pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear), + ), onSuccess: async () => { toast.success(role ? t('admin.roles.updated') : t('admin.roles.created')) await queryClient.invalidateQueries({ queryKey: ['admin-roles'] }) @@ -42,10 +63,17 @@ export function RoleFormDialog({ setName('') setMaxConfigs('3') setMaxIpLimit('2') + setPricePerConfigPerQuarter('') + setPricePerConfigPerYear('') }, onError: () => toast.error(t('auth.genericError')), }) + const totalFor = (price: string) => { + if (price === '' || Number(maxConfigs) < 0) return null + return Number(price) * Number(maxConfigs) + } + return ( {!isControlled && ( @@ -80,6 +108,40 @@ export function RoleFormDialog({ setMaxIpLimit(e.target.value)} />

{t('admin.roles.maxIpLimitHint')}

+ {!isAdmin && ( + <> +
+ + setPricePerConfigPerQuarter(e.target.value)} + /> +

+ {t('admin.roles.pricePerConfigPerQuarterHint')} + {totalFor(pricePerConfigPerQuarter) !== null && + ` ${t('admin.roles.totalValue', { total: totalFor(pricePerConfigPerQuarter) })}`} +

+
+
+ + setPricePerConfigPerYear(e.target.value)} + /> +

+ {t('admin.roles.pricePerConfigPerYearHint')} + {totalFor(pricePerConfigPerYear) !== null && + ` ${t('admin.roles.totalValue', { total: totalFor(pricePerConfigPerYear) })}`} +

+
+ + )} diff --git a/frontend/src/features/admin/roles/api.ts b/frontend/src/features/admin/roles/api.ts index a965f0a..86e7d9e 100644 --- a/frontend/src/features/admin/roles/api.ts +++ b/frontend/src/features/admin/roles/api.ts @@ -5,12 +5,30 @@ export function listRoles() { return apiRequest('/admin/roles') } -export function createRole(name: string, maxConfigs: number, maxIpLimit: number) { - return apiRequest('/admin/roles', { method: 'POST', body: { name, maxConfigs, maxIpLimit } }) +export function createRole( + name: string, + maxConfigs: number, + maxIpLimit: number, + pricePerConfigPerQuarter: number | null, + pricePerConfigPerYear: number | null, +) { + return apiRequest('/admin/roles', { + method: 'POST', + body: { name, maxConfigs, maxIpLimit, pricePerConfigPerQuarter, pricePerConfigPerYear }, + }) } -export function updateRole(id: string, maxConfigs: number, maxIpLimit: number) { - return apiRequest(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs, maxIpLimit } }) +export function updateRole( + id: string, + maxConfigs: number, + maxIpLimit: number, + pricePerConfigPerQuarter: number | null, + pricePerConfigPerYear: number | null, +) { + return apiRequest(`/admin/roles/${id}`, { + method: 'PUT', + body: { maxConfigs, maxIpLimit, pricePerConfigPerQuarter, pricePerConfigPerYear }, + }) } export function deleteRole(id: string) { diff --git a/frontend/src/features/support/api.ts b/frontend/src/features/support/api.ts index 6f3f166..c469331 100644 --- a/frontend/src/features/support/api.ts +++ b/frontend/src/features/support/api.ts @@ -1,7 +1,7 @@ import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client' import type { PagedList, - RoleDto, + SelectableRoleDto, TicketCommentDto, TicketDetailDto, TicketStatus, @@ -25,7 +25,7 @@ export function getTicket(id: string) { } export function listSelectableRoles() { - return apiRequest('/support/roles') + return apiRequest('/support/roles') } export function createBugReportTicket(message: string, files: File[]) { diff --git a/frontend/src/routes/admin/roles.tsx b/frontend/src/routes/admin/roles.tsx index dd76af4..f7390aa 100644 --- a/frontend/src/routes/admin/roles.tsx +++ b/frontend/src/routes/admin/roles.tsx @@ -18,6 +18,9 @@ function AdminRolesPage() { const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles }) + const totalPrice = (price: number | null, maxConfigs: number) => + price == null || maxConfigs < 0 ? t('admin.roles.noPrice') : `${price * maxConfigs} ₽` + const deleteMutation = useMutation({ mutationFn: deleteRole, onSuccess: async () => { @@ -52,6 +55,8 @@ function AdminRolesPage() { {t('admin.roles.name')} {t('admin.roles.maxConfigs')} {t('admin.roles.maxIpLimit')} + {t('admin.roles.totalPerQuarter')} + {t('admin.roles.totalPerYear')} @@ -64,6 +69,8 @@ function AdminRolesPage() { {role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs} {role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit} + {totalPrice(role.pricePerConfigPerQuarter, role.maxConfigs)} + {totalPrice(role.pricePerConfigPerYear, role.maxConfigs)}