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
+8 -1
View File
@@ -32,11 +32,18 @@ AdminSeed__Username=admin
AdminSeed__Password=change-me-strong-admin-password
# ── Роли по умолчанию ─────────────────────────────────────────────────────
# Квота конфигов для системной роли "user" (выдаётся при регистрации).
# Стартовая квота конфигов нового пользователя (AppUser.ConfigQuota при регистрации; далее меняется
# самостоятельно через страницу тарифа /plan, не привязана к роли).
Roles__DefaultUserMaxConfigs=3
# Лимит одновременных IP на клиента (limitIp в 3x-ui) для системной роли "user"; -1 = без лимита.
Roles__DefaultUserMaxIpLimit=2
# ── Тарифы (самообслуживание) ────────────────────────────────────────────
# Верхняя граница ручного ввода количества конфигов при смене тарифа — защита от абьюза.
Plans__MaxCustomConfigCount=50
# Нижняя граница ручного ввода — не даёт занизить квоту ниже разумного минимума.
Plans__MinCustomConfigCount=3
# ── Rate limiting ────────────────────────────────────────────────────────
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20.
# RateLimiting__AuthPermitLimit=20
@@ -0,0 +1,69 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Plans;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminPlanEndpoints
{
public static IEndpointRouteBuilder MapAdminPlanEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/plans")
.WithTags("Admin.Plans")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListPlans).Produces<IReadOnlyList<AdminPlanDto>>();
admin.MapPost("", CreatePlan).Produces<AdminPlanDto>();
admin.MapPut("/{id:guid}", UpdatePlan).Produces<AdminPlanDto>();
admin.MapDelete("/{id:guid}", DeletePlan).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListPlans(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListAdminPlansQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreatePlan(
CreatePlanCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdatePlan(
Guid id,
UpdatePlanBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new UpdatePlanCommand(
id,
body.Name,
body.ConfigCount,
body.SortOrder,
body.IsEnabled
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeletePlan(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeletePlanCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdatePlanBody(string Name, int ConfigCount, int SortOrder, bool IsEnabled);
@@ -27,12 +27,6 @@ public static class AdminSupportEndpoints
.MapPost("/tickets/{id:guid}/resolve", Resolve)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/approve-extension", ApproveExtensionRequest)
.Produces(StatusCodes.Status204NoContent);
@@ -102,30 +96,6 @@ public static class AdminSupportEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> ApproveRoleRequest(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ApproveRoleRequestCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RejectRoleRequest(
Guid id,
RejectRoleRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RejectRoleRequestCommand(id, body.Reason),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ApproveExtensionRequest(
Guid id,
ISender sender,
@@ -151,6 +121,4 @@ public static class AdminSupportEndpoints
}
}
public sealed record RejectRoleRequestBody(string? Reason);
public sealed record RejectExtensionRequestBody(string? Reason);
@@ -0,0 +1,55 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Plans;
namespace PnvPanel.Api.Endpoints;
public static class PlanEndpoints
{
public static IEndpointRouteBuilder MapPlanEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/plans").WithTags("Plans").RequireAuthorization();
group.MapGet("", ListPlans).Produces<IReadOnlyList<PlanDto>>();
group.MapGet("/status", GetMyPlanStatus).Produces<MyPlanStatusDto>();
group.MapPost("/change", ChangePlan).Produces<ChangePlanResultDto>();
return app;
}
private static async Task<IResult> ListPlans(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListPlansQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetMyPlanStatus(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetMyPlanStatusQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangePlan(
ChangePlanBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new ChangePlanCommand(
body.PlanId,
body.CustomConfigCount,
body.ConfigIdsToRevoke ?? []
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
}
public sealed record ChangePlanBody(
Guid? PlanId,
int? CustomConfigCount,
IReadOnlyList<Guid>? ConfigIdsToRevoke
);
@@ -22,6 +22,9 @@ public static class RoleEndpoints
admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/plan", SetUserPlan)
.Produces(StatusCodes.Status204NoContent);
return app;
}
@@ -53,7 +56,7 @@ public static class RoleEndpoints
)
{
var result = await sender.Send(
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit, body.BillingEnabled),
new UpdateRoleCommand(id, body.MaxIpLimit, body.BillingEnabled),
cancellationToken
);
return result.ToHttpResult();
@@ -82,8 +85,24 @@ public static class RoleEndpoints
);
return result.ToHttpResult();
}
private static async Task<IResult> SetUserPlan(
Guid id,
SetUserPlanBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AdminSetUserPlanCommand(id, body.PlanId, body.CustomConfigCount),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit, bool BillingEnabled);
public sealed record UpdateRoleBody(int MaxIpLimit, bool BillingEnabled);
public sealed record ChangeUserRoleBody(Guid RoleId);
public sealed record SetUserPlanBody(Guid? PlanId, int? CustomConfigCount);
@@ -7,12 +7,10 @@ using PnvPanel.Application.Support;
using PnvPanel.Application.Support.AddComment;
using PnvPanel.Application.Support.CreateBugReport;
using PnvPanel.Application.Support.CreateExtensionRequest;
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;
using PnvPanel.Application.Support.Reopen;
using PnvPanel.Domain.Support;
@@ -24,13 +22,11 @@ public static class SupportEndpoints
{
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
group.MapGet("/pricing", GetSupportPricing).Produces<PricingSettingsDto>();
group
.MapPost("/tickets/bug-reports", CreateBugReport)
.DisableAntiforgery()
.Produces<TicketDetailDto>();
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
group
.MapPost("/tickets/extension-requests", CreateExtensionRequest)
.Produces<TicketDetailDto>();
@@ -46,15 +42,6 @@ public static class SupportEndpoints
return app;
}
private static async Task<IResult> ListSelectableRoles(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListSelectableRolesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetSupportPricing(
ISender sender,
CancellationToken cancellationToken
@@ -76,23 +63,6 @@ public static class SupportEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> CreateRoleRequest(
CreateRoleRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new CreateRoleRequestTicketCommand(
body.ExistingRoleId,
body.NewRoleName,
body.NewRoleMaxConfigs,
body.NewRoleMaxIpLimit,
body.Justification
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateExtensionRequest(
CreateExtensionRequestBody body,
ISender sender,
@@ -182,14 +152,6 @@ public static class SupportEndpoints
}
}
public sealed record CreateRoleRequestBody(
Guid? ExistingRoleId,
string? NewRoleName,
int? NewRoleMaxConfigs,
int? NewRoleMaxIpLimit,
string Justification
);
public sealed record CreateExtensionRequestBody(int RequestedDays, string Justification);
public sealed record ListTicketsRequest(
+2
View File
@@ -184,11 +184,13 @@ app.MapInboundEndpoints();
app.MapConfigEndpoints();
app.MapSubscriptionEndpoints();
app.MapAppEndpoints();
app.MapPlanEndpoints();
app.MapNewsEndpoints();
app.MapInstructionEndpoints();
app.MapAdminUserEndpoints();
app.MapAdminStatsEndpoints();
app.MapAdminAppEndpoints();
app.MapAdminPlanEndpoints();
app.MapAdminNewsEndpoints();
app.MapAdminInstructionEndpoints();
app.MapAdminPricingEndpoints();
@@ -440,55 +440,6 @@ public sealed class PnvBotUpdateHandler(
break;
}
case "rrq":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveRoleRequestCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectRoleRequestCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Заявка одобрена, роль выдана."
: "❌ Заявка отклонена."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
}
case "erq":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
@@ -104,47 +104,6 @@ internal sealed class TelegramNotifier(
}
}
public async Task NotifyAdminsRoleRequestCreatedAsync(
Guid ticketId,
string userName,
string roleDescription,
string justification,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var text =
$"🆕 Заявка на роль от <b>{Escape(userName)}</b>\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}";
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq:reject:{ticketId}"),
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
}
}
}
public async Task NotifyAdminsExtensionRequestCreatedAsync(
Guid ticketId,
string userName,
@@ -196,8 +155,7 @@ internal sealed class TelegramNotifier(
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var typeLabel = type == TicketType.RoleRequest ? "заявка на роль" : "баг/предложение";
var text = $"🔓 Тикет от <b>{Escape(userName)}</b> переоткрыт ({typeLabel})";
var text = $"🔓 Тикет от <b>{Escape(userName)}</b> переоткрыт (баг/предложение)";
InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
@@ -280,7 +238,7 @@ internal sealed class TelegramNotifier(
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var reason = kind == PaymentRequestKind.RoleChangeTopUp ? "доплате за смену роли" : $"оплате за {PeriodLabel(period!.Value)}";
var reason = kind == PaymentRequestKind.PlanChangeTopUp ? "доплате за смену тарифа" : $"оплате за {PeriodLabel(period!.Value)}";
var text = $"💰 <b>{Escape(userName)}</b> заявляет о {reason} — {amount} ₽\nПроверьте поступление и подтвердите.";
var keyboard = new InlineKeyboardMarkup(
@@ -48,7 +48,7 @@ public sealed class ConfirmPaymentRequestCommandHandler(
var (request, newPaidUntil) = claimed.Value;
// RoleChangeTopUp — доплата разницы в цене при апгрейде роли, не покупка времени: подтверждение
// PlanChangeTopUp — доплата разницы в цене при увеличении тарифа, не покупка времени: подтверждение
// не возвращает Expired-конфиги (это делает обычная Subscription-оплата/продление).
if (request.Kind == PaymentRequestKind.Subscription)
{
@@ -75,8 +75,8 @@ public sealed class ConfirmPaymentRequestCommandHandler(
);
var message =
request.Kind == PaymentRequestKind.RoleChangeTopUp
? "✅ Доплата за смену роли подтверждена."
request.Kind == PaymentRequestKind.PlanChangeTopUp
? "✅ Доплата за смену тарифа подтверждена."
: $"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.";
await telegramNotifier.NotifyUserAsync(request.UserId, message, null, cancellationToken);
@@ -105,7 +105,7 @@ public sealed class ConfirmPaymentRequestCommandHandler(
)
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotDecidable);
if (request.Kind == PaymentRequestKind.RoleChangeTopUp)
if (request.Kind == PaymentRequestKind.PlanChangeTopUp)
{
request.Confirm(adminId);
return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, null));
@@ -0,0 +1,9 @@
using PnvPanel.Domain.Plans;
namespace PnvPanel.Application.Admin.Plans;
public sealed record AdminPlanDto(Guid Id, string Name, int ConfigCount, int SortOrder, bool IsEnabled)
{
public static AdminPlanDto FromDomain(Plan plan) =>
new(plan.Id, plan.Name, plan.ConfigCount, plan.SortOrder, plan.IsEnabled);
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Plans;
public sealed record CreatePlanCommand(string Name, int ConfigCount, int SortOrder)
: ICommand<Result<AdminPlanDto>>;
@@ -0,0 +1,21 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Plans;
namespace PnvPanel.Application.Admin.Plans;
public sealed class CreatePlanCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreatePlanCommand, Result<AdminPlanDto>>
{
public Task<Result<AdminPlanDto>> Handle(
CreatePlanCommand command,
CancellationToken cancellationToken
)
{
var plan = Plan.Create(command.Name, command.ConfigCount, command.SortOrder);
dbContext.Plans.Add(plan);
return Task.FromResult(Result.Success(AdminPlanDto.FromDomain(plan)));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Plans;
public sealed class CreatePlanCommandValidator : AbstractValidator<CreatePlanCommand>
{
public CreatePlanCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().Length(2, 64);
RuleFor(x => x.ConfigCount).GreaterThanOrEqualTo(1);
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Plans;
public sealed record DeletePlanCommand(Guid PlanId) : ICommand<Result>;
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Plans;
namespace PnvPanel.Application.Admin.Plans;
/// <summary>Удаление тарифа не трогает пользователей, у которых он уже выбран — ConfigQuota
/// снапшотится на AppUser в момент выбора (см. ChangePlanCommandHandler), PlanId лишь "последний
/// выбранный каталожный тариф" и может молча указывать на удалённую запись (как AllowedRoleIds
/// у Inbound — обычная колонка без FK, по конвенции проекта).</summary>
public sealed class DeletePlanCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeletePlanCommand, Result>
{
public async Task<Result> Handle(DeletePlanCommand command, CancellationToken cancellationToken)
{
var plan = await dbContext.Plans.FirstOrDefaultAsync(
p => p.Id == command.PlanId,
cancellationToken
);
if (plan is null)
return Result.Failure(PlanErrors.NotFound);
dbContext.Plans.Remove(plan);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Plans;
public sealed record ListAdminPlansQuery : IQuery<Result<IReadOnlyList<AdminPlanDto>>>;
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Plans;
public sealed class ListAdminPlansQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListAdminPlansQuery, Result<IReadOnlyList<AdminPlanDto>>>
{
public async Task<Result<IReadOnlyList<AdminPlanDto>>> Handle(
ListAdminPlansQuery query,
CancellationToken cancellationToken
)
{
var plans = await dbContext
.Plans.AsNoTracking()
.OrderBy(p => p.SortOrder)
.ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<AdminPlanDto>>(
plans.Select(AdminPlanDto.FromDomain).ToList()
);
}
}
@@ -0,0 +1,12 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Plans;
public sealed record UpdatePlanCommand(
Guid PlanId,
string Name,
int ConfigCount,
int SortOrder,
bool IsEnabled
) : ICommand<Result<AdminPlanDto>>;
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Plans;
namespace PnvPanel.Application.Admin.Plans;
public sealed class UpdatePlanCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdatePlanCommand, Result<AdminPlanDto>>
{
public async Task<Result<AdminPlanDto>> Handle(
UpdatePlanCommand command,
CancellationToken cancellationToken
)
{
var plan = await dbContext.Plans.FirstOrDefaultAsync(
p => p.Id == command.PlanId,
cancellationToken
);
if (plan is null)
return Result.Failure<AdminPlanDto>(PlanErrors.NotFound);
plan.Update(command.Name, command.ConfigCount, command.SortOrder, command.IsEnabled);
return Result.Success(AdminPlanDto.FromDomain(plan));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Plans;
public sealed class UpdatePlanCommandValidator : AbstractValidator<UpdatePlanCommand>
{
public UpdatePlanCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().Length(2, 64);
RuleFor(x => x.ConfigCount).GreaterThanOrEqualTo(1);
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
}
}
@@ -4,9 +4,5 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record CreateRoleCommand(
string Name,
int MaxConfigs,
int MaxIpLimit,
bool BillingEnabled
) : ICommand<Result<RoleDto>>;
public sealed record CreateRoleCommand(string Name, int MaxIpLimit, bool BillingEnabled)
: ICommand<Result<RoleDto>>;
@@ -13,7 +13,6 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService)
) =>
roleService.CreateRoleAsync(
command.Name,
command.MaxConfigs,
command.MaxIpLimit,
command.BillingEnabled,
cancellationToken
@@ -8,7 +8,6 @@ public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCom
{
RuleFor(x => x.Name).NotEmpty().Length(2, 32).Matches("^[a-zA-Z0-9_-]+$");
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
}
}
@@ -4,9 +4,5 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record UpdateRoleCommand(
Guid RoleId,
int MaxConfigs,
int MaxIpLimit,
bool BillingEnabled
) : ICommand<Result<RoleDto>>;
public sealed record UpdateRoleCommand(Guid RoleId, int MaxIpLimit, bool BillingEnabled)
: ICommand<Result<RoleDto>>;
@@ -13,7 +13,6 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService)
) =>
roleService.UpdateRoleAsync(
command.RoleId,
command.MaxConfigs,
command.MaxIpLimit,
command.BillingEnabled,
cancellationToken
@@ -6,7 +6,6 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCom
{
public UpdateRoleCommandValidator()
{
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
}
}
@@ -1,8 +0,0 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Support;
/// <summary>Одобрение — при новой роли сначала создаёт её (IRoleService.CreateRoleAsync), затем в
/// любом случае назначает пользователю (ChangeUserRoleAsync) и переводит тикет в Resolved.</summary>
public sealed record ApproveRoleRequestCommand(Guid TicketId) : ICommand<Result>;
@@ -1,188 +0,0 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
/// <summary>
/// Проверка статуса + создание/назначение роли + Resolve() + доплата (если роль подорожала) — под
/// AdvisoryLock (по Id тикета): без неё одобрение с сайта, гонящееся с одобрением из Telegram по одной
/// и той же заявке, могли бы оба пройти проверку "ещё не решена" и оба создать RoleChangeTopUp —
/// двойной счёт за один апгрейд.
/// </summary>
public sealed class ApproveRoleRequestCommandHandler(
IAppDbContext dbContext,
IRoleService roleService,
IIdentityService identityService,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<ApproveRoleRequestCommand, Result>
{
public async Task<Result> Handle(
ApproveRoleRequestCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var claimed = await AdvisoryLock.RunAsync(
dbContext,
command.TicketId,
lockedCancellationToken => ApproveAsync(command.TicketId, adminId, lockedCancellationToken),
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure(claimed.Error);
var (ticket, topUpAmount) = claimed.Value;
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"✅ Ваша заявка на роль одобрена.",
$"/support?ticket={ticket.Id}",
cancellationToken
);
if (topUpAmount is { } amount)
{
await telegramNotifier.NotifyUserAsync(
ticket.UserId,
$"💳 Новая роль дороже прежней — требуется доплата {amount} ₽ за оставшуюся часть оплаченного периода.",
"/billing",
cancellationToken
);
}
return Result.Success();
}
private async Task<Result<(SupportTicket Ticket, int? TopUpAmount)>> ApproveAsync(
Guid ticketId,
Guid adminId,
CancellationToken cancellationToken
)
{
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == ticketId,
cancellationToken
);
if (ticket is null)
return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotFound);
if (ticket.Type != TicketType.RoleRequest)
return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotRoleRequest);
if (ticket.Status != TicketStatus.Open)
return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotOpen);
// Снимаем профиль ДО смены роли — нужен старый MaxConfigs/BillingPaidUntil для проратированной
// доплаты за апгрейд (см. ниже), после ChangeUserRoleAsync эти данные уже недоступны.
var oldProfile = await identityService.GetProfileAsync(ticket.UserId, cancellationToken);
RoleDto newRole;
if (ticket.RequestedRoleId is { } existingRoleId)
{
var roles = await roleService.ListRolesAsync(cancellationToken);
var found = roles.FirstOrDefault(r => r.Id == existingRoleId);
if (found is null)
return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotFound);
newRole = found;
}
else
{
var createResult = await roleService.CreateRoleAsync(
ticket.ProposedRoleName!,
ticket.ProposedMaxConfigs!.Value,
ticket.ProposedMaxIpLimit!.Value,
billingEnabled: false,
cancellationToken
);
if (!createResult.IsSuccess)
return Result.Failure<(SupportTicket, int?)>(createResult.Error);
newRole = createResult.Value;
}
var assignResult = await roleService.ChangeUserRoleAsync(
ticket.UserId,
newRole.Id,
cancellationToken
);
if (!assignResult.IsSuccess)
return Result.Failure<(SupportTicket, int?)>(assignResult.Error);
ticket.Resolve();
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"RoleRequestApproved",
"SupportTicket",
ticket.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
int? topUpAmount = null;
if (newRole.BillingEnabled && oldProfile?.BillingPaidUntil is { } paidUntil)
{
topUpAmount = await CreateTopUpIfNeededAsync(
ticket.UserId,
oldProfile.MaxConfigs,
newRole.MaxConfigs,
paidUntil,
cancellationToken
);
}
return Result.Success((ticket, topUpAmount));
}
/// <summary>Роль подорожала, а оплаченный период ещё активен — по-хорошему пользователь должен
/// доплатить разницу, а не доиграть апгрейд бесплатно до конца уже оплаченного срока. Роль меняется
/// сразу (см. выше); доплата решается отдельно через обычный флоу PaymentRequest — см.
/// domain-model.md#rolechangetopup. Только БД — уведомление шлёт вызывающий код после снятия лока.</summary>
private async Task<int?> CreateTopUpIfNeededAsync(
Guid userId,
int oldMaxConfigs,
int newMaxConfigs,
DateTimeOffset paidUntil,
CancellationToken cancellationToken
)
{
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
if (pricing?.PricePerConfigPerQuarter is not { } rate)
return null;
var tiers = await dbContext
.PricingDiscountTiers.AsNoTracking()
.Where(t => t.PricingSettingsId == pricing.Id)
.ToListAsync(cancellationToken);
var amount = RoleChangeTopUp.Compute(
rate,
oldMaxConfigs,
newMaxConfigs,
tiers,
paidUntil,
DateTimeOffset.UtcNow
);
if (amount is not { } topUpAmount)
return null;
dbContext.PaymentRequests.Add(PaymentRequest.CreateRoleChangeTopUp(userId, topUpAmount));
return topUpAmount;
}
}
@@ -6,11 +6,8 @@ using PnvPanel.Application.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed class GetTicketAdminQueryHandler(
IAppDbContext dbContext,
IIdentityService identityService,
IRoleService roleService
) : IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
public sealed class GetTicketAdminQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
: IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
{
public async Task<Result<TicketDetailDto>> Handle(
GetTicketAdminQuery query,
@@ -26,7 +23,6 @@ public sealed class GetTicketAdminQueryHandler(
var dto = await TicketMapping.ToDetailDtoAsync(
dbContext,
identityService,
roleService,
ticket,
cancellationToken
);
@@ -10,8 +10,9 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
/// <summary>Проверка статуса + Close() — под AdvisoryLock (по Id тикета), см.
/// ApproveRoleRequestCommandHandler для полного обоснования.</summary>
/// <summary>Проверка статуса + Close() — под AdvisoryLock (по Id тикета) — без него отклонение с
/// сайта, гонящееся с отклонением из Telegram по одной и той же заявке, могли бы оба пройти
/// проверку статуса и оба закрыть тикет.</summary>
public sealed class RejectExtensionRequestCommandHandler(
IAppDbContext dbContext,
IRealtimeNotifier notifier,
@@ -1,6 +0,0 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Support;
public sealed record RejectRoleRequestCommand(Guid TicketId, string? Reason) : ICommand<Result>;
@@ -1,82 +0,0 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
/// <summary>Проверка статуса + Close() — под AdvisoryLock (по Id тикета), см.
/// ApproveRoleRequestCommandHandler для полного обоснования.</summary>
public sealed class RejectRoleRequestCommandHandler(
IAppDbContext dbContext,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<RejectRoleRequestCommand, Result>
{
public async Task<Result> Handle(
RejectRoleRequestCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var claimed = await AdvisoryLock.RunAsync(
dbContext,
command.TicketId,
async lockedCancellationToken =>
{
var fresh = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId,
lockedCancellationToken
);
if (fresh is null)
return Result.Failure<SupportTicket>(SupportErrors.NotFound);
if (fresh.Type != TicketType.RoleRequest)
return Result.Failure<SupportTicket>(SupportErrors.NotRoleRequest);
if (fresh.Status == TicketStatus.Closed)
return Result.Failure<SupportTicket>(SupportErrors.AlreadyClosed);
if (!string.IsNullOrWhiteSpace(command.Reason))
dbContext.TicketComments.Add(TicketComment.Create(fresh.Id, adminId, command.Reason));
fresh.Close();
return Result.Success(fresh);
},
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure(claimed.Error);
var ticket = claimed.Value;
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"RoleRequestRejected",
"SupportTicket",
ticket.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"❌ Ваша заявка на роль отклонена.",
$"/support?ticket={ticket.Id}",
cancellationToken
);
return Result.Success();
}
}
@@ -0,0 +1,13 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Users;
/// <summary>Прямой оверрайд квоты конфигов пользователя админом — ровно одно из PlanId/
/// CustomConfigCount. В отличие от самостоятельной смены тарифа (ChangePlanCommand): не требует
/// выбора конфигов на отзыв при понижении (грандфазеринг — как при понижении роли админом сегодня:
/// лишние конфиги не трогаются, новые блокируются, пока не войдёт в квоту) и не создаёт доплату
/// (осознанный инструмент админа, может использоваться как поощрение — тот же принцип, что и у
/// прямой смены роли, см. domain-model.md).</summary>
public sealed record AdminSetUserPlanCommand(Guid UserId, Guid? PlanId, int? CustomConfigCount)
: ICommand<Result>;
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Plans;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Users;
public sealed class AdminSetUserPlanCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<AdminSetUserPlanCommand, Result>
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin (Application не может
// ссылаться на Infrastructure).
private const string AdminRoleName = "admin";
public async Task<Result> Handle(
AdminSetUserPlanCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(UserErrors.NotFound);
int configQuota;
if (command.PlanId is { } planId)
{
var plan = await dbContext.Plans.AsNoTracking().FirstOrDefaultAsync(
p => p.Id == planId,
cancellationToken
);
if (plan is null)
return Result.Failure(PlanErrors.NotFound);
configQuota = plan.ConfigCount;
}
else
{
configQuota = command.CustomConfigCount!.Value;
}
// Безлимитная квота — только для admin (см. domain-model.md#approle) — иначе оверрайдом
// тарифа админ мог бы выдать безлимит обычному пользователю в обход этого инварианта.
if (configQuota == RoleQuota.Unlimited)
{
var targetProfile = await identityService.GetProfileAsync(command.UserId, cancellationToken);
if (targetProfile is null)
return Result.Failure(UserErrors.NotFound);
if (!targetProfile.Role.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
return Result.Failure(PlanErrors.UnlimitedOnlyForAdmin);
}
var result = await identityService.SetConfigQuotaAsync(
command.UserId,
configQuota,
command.PlanId,
cancellationToken
);
if (!result.IsSuccess)
return result;
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"UserPlanChanged",
"User",
command.UserId.ToString(),
metadata: $"{{\"newConfigQuota\":{configQuota}}}",
AuditSource.Web
)
);
return Result.Success();
}
}
@@ -0,0 +1,18 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Users;
public sealed class AdminSetUserPlanCommandValidator : AbstractValidator<AdminSetUserPlanCommand>
{
public AdminSetUserPlanCommandValidator()
{
RuleFor(x => x)
.Must(x => x.PlanId.HasValue ^ x.CustomConfigCount.HasValue)
.WithMessage("Укажите либо PlanId, либо CustomConfigCount — ровно одно из двух.");
When(
x => x.CustomConfigCount.HasValue,
() => RuleFor(x => x.CustomConfigCount!.Value).GreaterThanOrEqualTo(-1)
);
}
}
@@ -33,7 +33,7 @@ public sealed class CreatePaymentRequestCommandHandler(
if (!profile.BillingEnabled)
return Result.Failure<PaymentRequestDto>(BillingErrors.NotEnabled);
if (profile.MaxConfigs == RoleQuota.Unlimited)
if (profile.ConfigQuota == RoleQuota.Unlimited)
return Result.Failure<PaymentRequestDto>(BillingErrors.UnlimitedRoleNotSupported);
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
@@ -54,16 +54,16 @@ public sealed class CreatePaymentRequestCommandHandler(
.PricingDiscountTiers.AsNoTracking()
.Where(t => t.PricingSettingsId == pricing.Id)
.ToListAsync(cancellationToken);
var discountPercent = PricingDiscount.ResolvePercent(discountTiers, profile.MaxConfigs);
var discountPercent = PricingDiscount.ResolvePercent(discountTiers, profile.ConfigQuota);
var amount = PricingDiscount.Apply(rate * profile.MaxConfigs * command.Period.ToMonths(), discountPercent);
var amount = PricingDiscount.Apply(rate * profile.ConfigQuota * command.Period.ToMonths(), discountPercent);
var claimed = await AdvisoryLock.RunAsync(
dbContext,
userId,
async lockedCancellationToken =>
{
// RoleChangeTopUp не считается активной подписной заявкой — доплата за смену роли не
// PlanChangeTopUp не считается активной подписной заявкой — доплата за смену тарифа не
// должна мешать пользователю продлить/оформить обычную подписку.
var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync(
r =>
@@ -40,7 +40,7 @@ public sealed class MarkPaymentSentCommandHandler(
// Оплаченный период уже мог истечь, пока пользователь собирался заплатить — не ждём часовой
// тик BillingService, сразу держим клиента рабочим на панели на время рассмотрения заявки
// (см. BillingConfigResumer.ProtectPendingConfigsAsync). Только для Subscription — доплата за
// смену роли (RoleChangeTopUp) на приостановку не влияет.
// смену тарифа (PlanChangeTopUp) на приостановку не влияет.
if (
request.Kind == PaymentRequestKind.Subscription
&& (profile?.BillingPaidUntil is null || profile.BillingPaidUntil < DateTimeOffset.UtcNow)
@@ -46,7 +46,7 @@ public sealed class SendRequisitesToTelegramCommandHandler(
}
private static string DescribeRequest(PaymentRequest request) =>
request.Kind == PaymentRequestKind.RoleChangeTopUp ? "доплата за смену роли" : PeriodLabel(request.Period!.Value);
request.Kind == PaymentRequestKind.PlanChangeTopUp ? "доплата за смену тарифа" : PeriodLabel(request.Period!.Value);
private static string PeriodLabel(PaymentPeriod period) =>
period switch
@@ -9,6 +9,7 @@ using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions;
using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Plans;
using PnvPanel.Domain.Pricing;
using PnvPanel.Domain.Support;
using PnvPanel.Domain.Telegram;
@@ -55,6 +56,8 @@ public interface IAppDbContext
DbSet<PaymentRequest> PaymentRequests { get; }
DbSet<Plan> Plans { get; }
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
DatabaseFacade Database { get; }
@@ -11,7 +11,8 @@ public sealed record CurrentUserProfile(
string Role,
bool IsActivated,
bool IsBlocked,
int MaxConfigs,
int ConfigQuota,
Guid? PlanId,
int MaxIpLimit,
string SubscriptionToken,
bool BillingEnabled,
@@ -188,4 +189,14 @@ public interface IIdentityService
DateTimeOffset paidUntil,
CancellationToken cancellationToken
);
/// <summary>Устанавливает фактическую квоту конфигов пользователя и (опционально) какой
/// каталожный Plan выбран — вызывается из ChangePlanCommandHandler (самообслуживание) и
/// AdminSetUserPlanCommandHandler (оверрайд админом).</summary>
Task<Result> SetConfigQuotaAsync(
Guid userId,
int configQuota,
Guid? planId,
CancellationToken cancellationToken
);
}
@@ -0,0 +1,10 @@
namespace PnvPanel.Application.Common.Interfaces;
/// <summary>
/// Сидинг дефолтного каталога тарифов (Plan). Идемпотентно — не трогает таблицу, если в ней уже
/// есть строки (используется и при старте, и после полного сброса панели).
/// </summary>
public interface IPlanSeeder
{
Task SeedIfEmptyAsync(CancellationToken cancellationToken);
}
@@ -2,20 +2,12 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Interfaces;
public sealed record RoleDto(
Guid Id,
string Name,
int MaxConfigs,
int MaxIpLimit,
bool IsSystem,
bool BillingEnabled
);
public sealed record RoleDto(Guid Id, string Name, int MaxIpLimit, bool IsSystem, bool BillingEnabled);
public interface IRoleService
{
Task<Result<RoleDto>> CreateRoleAsync(
string name,
int maxConfigs,
int maxIpLimit,
bool billingEnabled,
CancellationToken cancellationToken
@@ -23,7 +15,6 @@ public interface IRoleService
Task<Result<RoleDto>> UpdateRoleAsync(
Guid roleId,
int maxConfigs,
int maxIpLimit,
bool billingEnabled,
CancellationToken cancellationToken
@@ -32,15 +32,6 @@ public interface ITelegramNotifier
CancellationToken cancellationToken
);
/// <summary>Заявка на роль — инлайн-кнопки «Одобрить/Отклонить», решается полностью в Telegram.</summary>
Task NotifyAdminsRoleRequestCreatedAsync(
Guid ticketId,
string userName,
string roleDescription,
string justification,
CancellationToken cancellationToken
);
/// <summary>Рассылка о публикации новости всем активированным пользователям с привязанным Telegram
/// (кнопка-ссылка на сайт, где новость показана целиком).</summary>
Task NotifyUsersNewsPublishedAsync(string title, CancellationToken cancellationToken);
@@ -55,8 +46,8 @@ public interface ITelegramNotifier
);
/// <summary>Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается
/// полностью в Telegram (аналогично заявке на роль). Period задан только для Kind.Subscription —
/// для Kind.RoleChangeTopUp он null (доплата не привязана к тарифному периоду).</summary>
/// полностью в Telegram. Period задан только для Kind.Subscription — для Kind.PlanChangeTopUp он
/// null (доплата не привязана к тарифному периоду).</summary>
Task NotifyAdminsPaymentRequestedAsync(
Guid requestId,
string userName,
@@ -67,7 +58,7 @@ public interface ITelegramNotifier
);
/// <summary>Заявка на продление оплаченного периода — инлайн-кнопки «Одобрить/Отклонить», решается
/// полностью в Telegram (аналогично заявке на роль).</summary>
/// полностью в Telegram.</summary>
Task NotifyAdminsExtensionRequestCreatedAsync(
Guid ticketId,
string userName,
@@ -59,7 +59,7 @@ public sealed class CreateVpnConfigCommandHandler(
var reserveResult = await ReserveQuotaSlotAsync(
userId,
profile.MaxConfigs,
profile.ConfigQuota,
config,
cancellationToken
);
@@ -103,7 +103,7 @@ public sealed class CreateVpnConfigCommandHandler(
/// </summary>
private async Task<Result> ReserveQuotaSlotAsync(
Guid userId,
int maxConfigs,
int configQuota,
VpnConfig config,
CancellationToken cancellationToken
)
@@ -122,7 +122,7 @@ public sealed class CreateVpnConfigCommandHandler(
cancellationToken
);
if (maxConfigs != RoleQuota.Unlimited && activeCount >= maxConfigs)
if (configQuota != RoleQuota.Unlimited && activeCount >= configQuota)
{
await transaction.RollbackAsync(cancellationToken);
return Result.Failure(ConfigErrors.QuotaExceeded);
@@ -5,4 +5,8 @@ namespace PnvPanel.Application.Configs.GetMyConfigs;
public sealed record GetMyConfigsQuery : IQuery<Result<GetMyConfigsResult>>, IRequiresActivation;
public sealed record GetMyConfigsResult(IReadOnlyList<VpnConfigDto> Configs, int MaxConfigs);
public sealed record GetMyConfigsResult(
IReadOnlyList<VpnConfigDto> Configs,
int ConfigQuota,
Guid? PlanId
);
@@ -39,6 +39,6 @@ public sealed class GetMyConfigsQueryHandler(
var dtos = rows.Select(x => VpnConfigDto.FromDomain(x.Config, x.Inbound)).ToList();
return Result.Success(new GetMyConfigsResult(dtos, profile.MaxConfigs));
return Result.Success(new GetMyConfigsResult(dtos, profile.ConfigQuota, profile.PlanId));
}
}
@@ -34,42 +34,16 @@ public sealed class RevokeVpnConfigCommandHandler(
if (config.Status == ConfigStatus.Revoked)
return Result.Success();
var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null
? null
: await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
var revokeResult = await VpnConfigRevocation.RevokeAsync(
dbContext,
gateway,
config,
logger,
cancellationToken
);
if (!revokeResult.IsSuccess)
return revokeResult;
// inbound.IsAvailable=false значит синхронизация уже подтвердила, что его нет на панели —
// звать RemoveClientAsync незачем (и оно всё равно упадёт: инбаунда для клиента не существует).
if (inbound is not null && inbound.IsAvailable && node is not null)
{
var removeResult = await gateway.RemoveClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
cancellationToken
);
if (!removeResult.IsSuccess)
{
// Нода недоступна/сбой панели — не помечаем конфиг Revoked локально, иначе БД
// разойдётся с реальным состоянием клиента в 3x-ui. Пользователь может повторить.
logger.LogWarning(
"Failed to remove client for config {ConfigId} on node {NodeId}: {Error}",
config.Id,
node.Id,
removeResult.Error
);
return Result.Failure(ConfigErrors.NodeUnavailable);
}
}
config.Revoke();
await notifier.NotifyConfigStatusChangedAsync(
userId,
config.Id,
@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Configs;
/// <summary>
/// Общий шаг «удалить клиента в 3x-ui (если инбаунд ещё доступен) → пометить конфиг Revoked» —
/// используется и одиночным отзывом (RevokeVpnConfigCommandHandler), и массовым отзывом при
/// самостоятельном понижении тарифа (ChangePlanCommandHandler). Не вызывает SaveChangesAsync —
/// это ответственность вызывающей стороны/AdvisoryLock.
/// </summary>
internal static class VpnConfigRevocation
{
public static async Task<Result> RevokeAsync(
IAppDbContext dbContext,
IXuiPanelGateway gateway,
VpnConfig config,
ILogger logger,
CancellationToken cancellationToken
)
{
if (config.Status == ConfigStatus.Revoked)
return Result.Success();
var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null
? null
: await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
// inbound.IsAvailable=false значит синхронизация уже подтвердила, что его нет на панели —
// звать RemoveClientAsync незачем (и оно всё равно упадёт: инбаунда для клиента не существует).
if (inbound is not null && inbound.IsAvailable && node is not null)
{
var removeResult = await gateway.RemoveClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
cancellationToken
);
if (!removeResult.IsSuccess)
{
// Нода недоступна/сбой панели — не помечаем конфиг Revoked локально, иначе БД
// разойдётся с реальным состоянием клиента в 3x-ui. Пользователь может повторить.
logger.LogWarning(
"Failed to remove client for config {ConfigId} on node {NodeId}: {Error}",
config.Id,
node.Id,
removeResult.Error
);
return Result.Failure(ConfigErrors.NodeUnavailable);
}
}
config.Revoke();
return Result.Success();
}
}
@@ -0,0 +1,16 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Plans;
/// <summary>Ровно одно из PlanId/CustomConfigCount задано. ConfigIdsToRevoke обязателен и должен
/// содержать ровно (текущее число конфигов, занимающих квоту (Active + Expired) - новая квота) id,
/// если новая квота меньше этого числа — самостоятельное понижение тарифа требует явного выбора,
/// какие конфиги отозвать, а не молчаливый грандфазеринг (см. ChangePlanCommandHandler).</summary>
public sealed record ChangePlanCommand(
Guid? PlanId,
int? CustomConfigCount,
IReadOnlyList<Guid> ConfigIdsToRevoke
) : ICommand<Result<ChangePlanResultDto>>, IRequiresActivation;
public sealed record ChangePlanResultDto(int ConfigQuota, Guid? PlanId, int? TopUpAmount);
@@ -0,0 +1,229 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Configs;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Application.Plans;
/// <summary>
/// Смена тарифа применяется сразу (без подтверждения админа). При увеличении квоты с активным
/// оплаченным периодом billing-роли создаётся доплата (PlanChangeTopUp), как при апгрейде роли
/// раньше. При уменьшении квоты ниже текущего числа конфигов, которые всё ещё занимают место в
/// квоте (Active + Expired — см. ниже), пользователь обязан явно выбрать, какие отозвать
/// (ConfigIdsToRevoke) — не молчаливый грандфазеринг, в отличие от понижения роли/квоты админом
/// (см. AdminSetUserPlanCommandHandler).
/// </summary>
public sealed class ChangePlanCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser,
ILogger<ChangePlanCommandHandler> logger
) : ICommandHandler<ChangePlanCommand, Result<ChangePlanResultDto>>
{
public async Task<Result<ChangePlanResultDto>> Handle(
ChangePlanCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<ChangePlanResultDto>(AuthErrors.Unauthorized);
int newConfigCount;
Guid? planId = command.PlanId;
if (command.PlanId is { } requestedPlanId)
{
var plan = await dbContext.Plans.AsNoTracking().FirstOrDefaultAsync(
p => p.Id == requestedPlanId,
cancellationToken
);
if (plan is null)
return Result.Failure<ChangePlanResultDto>(PlanErrors.NotFound);
if (!plan.IsEnabled)
return Result.Failure<ChangePlanResultDto>(PlanErrors.Disabled);
newConfigCount = plan.ConfigCount;
}
else
{
newConfigCount = command.CustomConfigCount!.Value;
}
var claimed = await AdvisoryLock.RunAsync(
dbContext,
userId,
lockedCancellationToken =>
ReserveAsync(
userId,
newConfigCount,
planId,
command.ConfigIdsToRevoke,
lockedCancellationToken
),
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure<ChangePlanResultDto>(claimed.Error);
var (configsToRevoke, topUpAmount) = claimed.Value;
foreach (var config in configsToRevoke)
{
var revokeResult = await VpnConfigRevocation.RevokeAsync(
dbContext,
gateway,
config,
logger,
cancellationToken
);
if (!revokeResult.IsSuccess)
return Result.Failure<ChangePlanResultDto>(revokeResult.Error);
}
if (configsToRevoke.Count > 0)
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var config in configsToRevoke)
{
await notifier.NotifyConfigStatusChangedAsync(
userId,
config.Id,
config.Status,
cancellationToken
);
}
if (topUpAmount is { } amount)
{
await telegramNotifier.NotifyUserAsync(
userId,
$"💳 Тариф увеличен — доплата {amount} ₽ за оставшийся оплаченный период.",
"/billing",
cancellationToken
);
}
return Result.Success(new ChangePlanResultDto(newConfigCount, planId, topUpAmount));
}
private async Task<Result<(IReadOnlyList<VpnConfig> ConfigsToRevoke, int? TopUpAmount)>> ReserveAsync(
Guid userId,
int newConfigCount,
Guid? planId,
IReadOnlyList<Guid> configIdsToRevoke,
CancellationToken cancellationToken
)
{
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<(IReadOnlyList<VpnConfig>, int?)>(AuthErrors.Unauthorized);
var oldConfigCount = profile.ConfigQuota;
// Expired (приостановленные за неуплату) всё ещё "занимают" квоту — они возвращаются в
// Active целиком при следующей оплате (BillingConfigResumer.ResumeConfigsAsync), которая
// квоту не проверяет. Если считать здесь только Active, пользователь мог бы обойти пикер
// отзыва, понизив тариф именно в момент приостановки (все конфиги временно не Active), а
// затем оплатить — и получить обратно больше конфигов, чем позволяет новый тариф.
var liveConfigs = await dbContext
.VpnConfigs.Where(c =>
c.UserId == userId
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
)
.ToListAsync(cancellationToken);
var excess = liveConfigs.Count - newConfigCount;
var configsToRevoke = new List<VpnConfig>();
if (excess > 0)
{
if (configIdsToRevoke.Count != excess)
return Result.Failure<(IReadOnlyList<VpnConfig>, int?)>(
PlanErrors.MustSelectConfigsToRevoke(excess)
);
configsToRevoke = liveConfigs.Where(c => configIdsToRevoke.Contains(c.Id)).ToList();
if (configsToRevoke.Count != excess)
return Result.Failure<(IReadOnlyList<VpnConfig>, int?)>(ConfigErrors.NotFound);
}
var quotaResult = await identityService.SetConfigQuotaAsync(
userId,
newConfigCount,
planId,
cancellationToken
);
if (!quotaResult.IsSuccess)
return Result.Failure<(IReadOnlyList<VpnConfig>, int?)>(quotaResult.Error);
int? topUpAmount = null;
if (
profile.BillingEnabled
&& newConfigCount > oldConfigCount
&& profile.BillingPaidUntil is { } paidUntil
)
{
topUpAmount = await CreateTopUpIfNeededAsync(
userId,
oldConfigCount,
newConfigCount,
paidUntil,
cancellationToken
);
}
dbContext.AuditLogs.Add(
AuditLog.Create(
userId,
"PlanChanged",
"User",
userId.ToString(),
metadata: $"{{\"oldConfigQuota\":{oldConfigCount},\"newConfigQuota\":{newConfigCount}}}",
AuditSource.Web
)
);
return Result.Success<(IReadOnlyList<VpnConfig>, int?)>((configsToRevoke, topUpAmount));
}
private async Task<int?> CreateTopUpIfNeededAsync(
Guid userId,
int oldConfigCount,
int newConfigCount,
DateTimeOffset paidUntil,
CancellationToken cancellationToken
)
{
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
if (pricing?.PricePerConfigPerQuarter is not { } rate)
return null;
var tiers = await dbContext
.PricingDiscountTiers.AsNoTracking()
.Where(t => t.PricingSettingsId == pricing.Id)
.ToListAsync(cancellationToken);
var amount = PlanChangeTopUp.Compute(
rate,
oldConfigCount,
newConfigCount,
tiers,
paidUntil,
DateTimeOffset.UtcNow
);
if (amount is not { } topUpAmount)
return null;
dbContext.PaymentRequests.Add(PaymentRequest.CreatePlanChangeTopUp(userId, topUpAmount));
return topUpAmount;
}
}
@@ -0,0 +1,23 @@
using FluentValidation;
using Microsoft.Extensions.Options;
namespace PnvPanel.Application.Plans;
public sealed class ChangePlanCommandValidator : AbstractValidator<ChangePlanCommand>
{
public ChangePlanCommandValidator(IOptions<PlansOptions> plansOptions)
{
RuleFor(x => x)
.Must(x => x.PlanId.HasValue ^ x.CustomConfigCount.HasValue)
.WithMessage("Укажите либо PlanId, либо CustomConfigCount — ровно одно из двух.");
When(
x => x.CustomConfigCount.HasValue,
() =>
RuleFor(x => x.CustomConfigCount!.Value)
.InclusiveBetween(plansOptions.Value.MinCustomConfigCount, plansOptions.Value.MaxCustomConfigCount)
);
RuleFor(x => x.ConfigIdsToRevoke).Must(ids => ids.Distinct().Count() == ids.Count);
}
}
@@ -0,0 +1,14 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Plans;
public sealed record GetMyPlanStatusQuery : IQuery<Result<MyPlanStatusDto>>, IRequiresActivation;
public sealed record MyPlanStatusDto(
int ConfigQuota,
Guid? PlanId,
int ActiveConfigCount,
bool BillingEnabled,
DateTimeOffset? BillingPaidUntil
);
@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Plans;
public sealed class GetMyPlanStatusQueryHandler(
IAppDbContext dbContext,
IIdentityService identityService,
ICurrentUser currentUser
) : IQueryHandler<GetMyPlanStatusQuery, Result<MyPlanStatusDto>>
{
public async Task<Result<MyPlanStatusDto>> Handle(
GetMyPlanStatusQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<MyPlanStatusDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<MyPlanStatusDto>(AuthErrors.Unauthorized);
var activeCount = await dbContext.VpnConfigs.CountAsync(
c => c.UserId == userId && c.Status == ConfigStatus.Active,
cancellationToken
);
return Result.Success(
new MyPlanStatusDto(
profile.ConfigQuota,
profile.PlanId,
activeCount,
profile.BillingEnabled,
profile.BillingPaidUntil
)
);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Plans;
public sealed record ListPlansQuery : IQuery<Result<IReadOnlyList<PlanDto>>>, IRequiresActivation;
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Plans;
public sealed class ListPlansQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListPlansQuery, Result<IReadOnlyList<PlanDto>>>
{
public async Task<Result<IReadOnlyList<PlanDto>>> Handle(
ListPlansQuery query,
CancellationToken cancellationToken
)
{
var plans = await dbContext
.Plans.AsNoTracking()
.Where(p => p.IsEnabled)
.OrderBy(p => p.SortOrder)
.ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<PlanDto>>(plans.Select(PlanDto.FromDomain).ToList());
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Domain.Plans;
namespace PnvPanel.Application.Plans;
public sealed record PlanDto(Guid Id, string Name, int ConfigCount)
{
public static PlanDto FromDomain(Plan plan) => new(plan.Id, plan.Name, plan.ConfigCount);
}
@@ -0,0 +1,26 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Plans;
public static class PlanErrors
{
public static readonly Error NotFound = Error.NotFound("Plans.NotFound", "Тариф не найден.");
public static readonly Error Disabled = Error.Validation(
"Plans.Disabled",
"Тариф отключён."
);
public static Error MustSelectConfigsToRevoke(int requiredCount) =>
Error.Validation(
"Plans.MustSelectConfigsToRevoke",
$"Новый тариф меньше текущего количества активных конфигов — выберите {requiredCount} конфиг(ов) для отзыва."
);
/// <summary>Безлимитная квота (-1) зарезервирована за ролью admin — обычному пользователю
/// админ не может выставить безлимит напрямую через оверрайд тарифа.</summary>
public static readonly Error UnlimitedOnlyForAdmin = Error.Validation(
"Plans.UnlimitedOnlyForAdmin",
"Безлимитная квота конфигов доступна только для роли admin."
);
}
@@ -0,0 +1,14 @@
namespace PnvPanel.Application.Plans;
public sealed class PlansOptions
{
public const string SectionName = "Plans";
/// <summary>Верхняя граница ручного ввода количества конфигов в ChangePlanCommand — защита от
/// абьюза (пользователь мог бы иначе указать сколь угодно большое число).</summary>
public int MaxCustomConfigCount { get; init; } = 50;
/// <summary>Нижняя граница ручного ввода — не даёт самому себе занизить квоту ниже разумного
/// минимума (по умолчанию совпадает с дефолтной квотой при регистрации, Roles__DefaultUserMaxConfigs).</summary>
public int MinCustomConfigCount { get; init; } = 3;
}
@@ -73,11 +73,6 @@ public sealed class CreateBugReportTicketCommandHandler(
ticket.Type,
ticket.Status,
null,
null,
null,
null,
null,
null,
ticket.CreatedAt,
[commentDto]
);
@@ -95,11 +95,6 @@ public sealed class CreateExtensionRequestTicketCommandHandler(
userName,
ticket.Type,
ticket.Status,
null,
null,
null,
null,
null,
ticket.RequestedDays,
ticket.CreatedAt,
[commentDto]
@@ -1,14 +0,0 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.CreateRoleRequest;
/// <summary>Ровно один из вариантов: ExistingRoleId, либо NewRoleName+NewRoleMaxConfigs+NewRoleMaxIpLimit
/// (проверяется валидатором).</summary>
public sealed record CreateRoleRequestTicketCommand(
Guid? ExistingRoleId,
string? NewRoleName,
int? NewRoleMaxConfigs,
int? NewRoleMaxIpLimit,
string Justification
) : ICommand<Result<TicketDetailDto>>, IRequiresActivation;
@@ -1,131 +0,0 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.CreateRoleRequest;
/// <summary>Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см.
/// CreatePaymentRequestCommandHandler для полного обоснования.</summary>
public sealed class CreateRoleRequestTicketCommandHandler(
IAppDbContext dbContext,
IRoleService roleService,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<CreateRoleRequestTicketCommand, Result<TicketDetailDto>>
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — Application не может ссылаться
// на Infrastructure (направление зависимостей), поэтому системное имя роли продублировано здесь.
private const string AdminRoleName = "admin";
public async Task<Result<TicketDetailDto>> Handle(
CreateRoleRequestTicketCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
string? requestedRoleName = null;
if (command.ExistingRoleId is { } existingRoleId)
{
var roles = await roleService.ListRolesAsync(cancellationToken);
var role = roles.FirstOrDefault(r => r.Id == existingRoleId);
if (role is null)
return Result.Failure<TicketDetailDto>(SupportErrors.RoleNotFound);
if (role.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
return Result.Failure<TicketDetailDto>(SupportErrors.CannotRequestAdminRole);
requestedRoleName = role.Name;
}
var claimed = await AdvisoryLock.RunAsync(
dbContext,
userId,
async lockedCancellationToken =>
{
var hasPending = await dbContext.SupportTickets.AnyAsync(
t =>
t.UserId == userId
&& t.Type == TicketType.RoleRequest
&& t.Status == TicketStatus.Open,
lockedCancellationToken
);
if (hasPending)
return Result.Failure<(SupportTicket, TicketComment)>(
SupportErrors.RoleRequestAlreadyPending
);
var newTicket =
command.ExistingRoleId is { } roleId
? SupportTicket.CreateRoleRequestForExistingRole(userId, roleId)
: SupportTicket.CreateRoleRequestForNewRole(
userId,
command.NewRoleName!,
command.NewRoleMaxConfigs!.Value,
command.NewRoleMaxIpLimit!.Value
);
dbContext.SupportTickets.Add(newTicket);
var newComment = TicketComment.Create(newTicket.Id, userId, command.Justification);
dbContext.TicketComments.Add(newComment);
return Result.Success((newTicket, newComment));
},
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure<TicketDetailDto>(claimed.Error);
var (ticket, comment) = claimed.Value;
var userName = currentUser.UserName ?? userId.ToString();
var roleDescription =
requestedRoleName
?? $"новая роль «{command.NewRoleName}» (конфигов: {command.NewRoleMaxConfigs}, IP: {command.NewRoleMaxIpLimit})";
await notifier.NotifyTicketCreatedAsync(
ticket.Id,
userId,
userName,
ticket.Type,
cancellationToken
);
await telegramNotifier.NotifyAdminsRoleRequestCreatedAsync(
ticket.Id,
userName,
roleDescription,
command.Justification,
cancellationToken
);
var commentDto = new TicketCommentDto(
comment.Id,
userId,
userName,
comment.Body,
comment.CreatedAt,
[]
);
var dto = new TicketDetailDto(
ticket.Id,
ticket.UserId,
userName,
ticket.Type,
ticket.Status,
ticket.RequestedRoleId,
requestedRoleName,
ticket.ProposedRoleName,
ticket.ProposedMaxConfigs,
ticket.ProposedMaxIpLimit,
ticket.RequestedDays,
ticket.CreatedAt,
[commentDto]
);
return Result.Success(dto);
}
}
@@ -1,39 +0,0 @@
using FluentValidation;
namespace PnvPanel.Application.Support.CreateRoleRequest;
public sealed class CreateRoleRequestTicketCommandValidator
: AbstractValidator<CreateRoleRequestTicketCommand>
{
public CreateRoleRequestTicketCommandValidator()
{
RuleFor(x => x.Justification).NotEmpty().MaximumLength(4000);
RuleFor(x => x)
.Must(HaveExactlyOnePayload)
.WithMessage(
"Укажите либо существующую роль, либо параметры новой (не оба варианта и не ни одного)."
);
When(
x => x.ExistingRoleId is null,
() =>
{
RuleFor(x => x.NewRoleName).NotEmpty().MaximumLength(100);
RuleFor(x => x.NewRoleMaxConfigs).NotNull().GreaterThanOrEqualTo(-1);
RuleFor(x => x.NewRoleMaxIpLimit).NotNull().GreaterThanOrEqualTo(-1);
}
);
}
private static bool HaveExactlyOnePayload(CreateRoleRequestTicketCommand command)
{
var hasExisting = command.ExistingRoleId is not null;
var hasNew =
!string.IsNullOrWhiteSpace(command.NewRoleName)
&& command.NewRoleMaxConfigs is not null
&& command.NewRoleMaxIpLimit is not null;
return hasExisting ^ hasNew;
}
}
@@ -9,7 +9,6 @@ namespace PnvPanel.Application.Support.GetTicket;
public sealed class GetTicketQueryHandler(
IAppDbContext dbContext,
IIdentityService identityService,
IRoleService roleService,
ICurrentUser currentUser
) : IQueryHandler<GetTicketQuery, Result<TicketDetailDto>>
{
@@ -33,7 +32,6 @@ public sealed class GetTicketQueryHandler(
var dto = await TicketMapping.ToDetailDtoAsync(
dbContext,
identityService,
roleService,
ticket,
cancellationToken
);
@@ -1,11 +0,0 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.ListSelectableRoles;
/// <summary>Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery
/// (Admin/Roles), доступен любому активированному пользователю.</summary>
public sealed record ListSelectableRolesQuery
: IQuery<Result<IReadOnlyList<RoleDto>>>,
IRequiresActivation;
@@ -1,37 +0,0 @@
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.ListSelectableRoles;
public sealed class ListSelectableRolesQueryHandler(
IRoleService roleService,
IIdentityService identityService,
ICurrentUser currentUser
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<RoleDto>>>
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
private const string AdminRoleName = "admin";
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
ListSelectableRolesQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<IReadOnlyList<RoleDto>>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
var roles = await roleService.ListRolesAsync(cancellationToken);
// Без admin (нельзя запросить) и без текущей роли пользователя (уже есть — нечего запрашивать).
var selectable = roles
.Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
.Where(r => profile is null || r.Id != profile.RoleId)
.ToList();
return Result.Success<IReadOnlyList<RoleDto>>(selectable);
}
}
@@ -9,15 +9,6 @@ public static class SupportErrors
"Support.AttachmentNotFound",
"Вложение не найдено."
);
public static readonly Error RoleNotFound = Error.NotFound(
"Support.RoleNotFound",
"Роль не найдена."
);
public static readonly Error CannotRequestAdminRole = Error.Forbidden(
"Support.CannotRequestAdminRole",
"Роль администратора нельзя запросить через заявку."
);
public static readonly Error TicketClosed = Error.Conflict(
"Support.TicketClosed",
@@ -38,34 +29,24 @@ public static class SupportErrors
"Переоткрыть можно только решённый тикет."
);
/// <summary>Заявка на роль/продление решается только через approve/reject — там же выполняется
/// сама выдача роли/дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего
/// не выдав пользователю.</summary>
/// <summary>Заявка на продление решается только через approve/reject — там же выполняется сама
/// выдача дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего не выдав
/// пользователю.</summary>
public static readonly Error OnlyBugReportCanBeResolvedDirectly = Error.Validation(
"Support.OnlyBugReportCanBeResolvedDirectly",
"Заявку на роль или продление можно только одобрить/отклонить, а не решить напрямую."
"Заявку на продление можно только одобрить/отклонить, а не решить напрямую."
);
public static readonly Error OnlyBugReportCanBeClosedDirectly = Error.Validation(
"Support.OnlyBugReportCanBeClosedDirectly",
"Заявку на роль или продление можно только одобрить/отклонить, а не закрыть напрямую."
"Заявку на продление можно только одобрить/отклонить, а не закрыть напрямую."
);
/// <summary>Заявка на роль/продление одноразовая: повторное одобрение начислило бы дни/роль ещё
/// раз. Новый запрос — новый тикет, не переоткрытие старого.</summary>
/// <summary>Заявка на продление одноразовая: повторное одобрение начислило бы дни ещё раз. Новый
/// запрос — новый тикет, не переоткрытие старого.</summary>
public static readonly Error OnlyBugReportCanBeReopened = Error.Validation(
"Support.OnlyBugReportCanBeReopened",
"Заявку на роль или продление нельзя переоткрыть — оформите новую."
);
public static readonly Error RoleRequestAlreadyPending = Error.Conflict(
"Support.RoleRequestAlreadyPending",
"У вас уже есть необработанная заявка на роль."
);
public static readonly Error NotRoleRequest = Error.Validation(
"Support.NotRoleRequest",
"Это не заявка на роль."
"Заявку на продление нельзя переоткрыть — оформите новую."
);
public static readonly Error ExtensionRequestAlreadyPending = Error.Conflict(
@@ -4,8 +4,8 @@ namespace PnvPanel.Application.Support;
internal static class TicketAuthorization
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin (Application не может
// ссылаться на Infrastructure).
private const string AdminRoleName = "admin";
public static bool IsAdmin(CurrentUserProfile? profile) =>
@@ -3,8 +3,6 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support;
/// <summary>
/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером,
/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName.
/// RequestedDays — только для ExtensionRequest (продление оплаченного периода).
/// </summary>
public sealed record TicketDetailDto(
@@ -13,11 +11,6 @@ public sealed record TicketDetailDto(
string UserName,
TicketType Type,
TicketStatus Status,
Guid? RequestedRoleId,
string? RequestedRoleName,
string? ProposedRoleName,
int? ProposedMaxConfigs,
int? ProposedMaxIpLimit,
int? RequestedDays,
DateTimeOffset CreatedAt,
IReadOnlyList<TicketCommentDto> Comments
@@ -15,7 +15,6 @@ internal static class TicketMapping
public static async Task<TicketDetailDto> ToDetailDtoAsync(
IAppDbContext dbContext,
IIdentityService identityService,
IRoleService roleService,
SupportTicket ticket,
CancellationToken cancellationToken
)
@@ -39,13 +38,6 @@ internal static class TicketMapping
var userIds = comments.Select(c => c.AuthorId).Append(ticket.UserId).Distinct().ToList();
var userNames = await identityService.GetUserNamesAsync(userIds, cancellationToken);
string? requestedRoleName = null;
if (ticket.RequestedRoleId is { } roleId)
{
var roles = await roleService.ListRolesAsync(cancellationToken);
requestedRoleName = roles.FirstOrDefault(r => r.Id == roleId)?.Name;
}
var commentDtos = comments
.Select(c => new TicketCommentDto(
c.Id,
@@ -71,11 +63,6 @@ internal static class TicketMapping
userNames.GetValueOrDefault(ticket.UserId, "?"),
ticket.Type,
ticket.Status,
ticket.RequestedRoleId,
requestedRoleName,
ticket.ProposedRoleName,
ticket.ProposedMaxConfigs,
ticket.ProposedMaxIpLimit,
ticket.RequestedDays,
ticket.CreatedAt,
commentDtos
@@ -5,14 +5,14 @@ namespace PnvPanel.Domain.Billing;
/// <summary>
/// Заявка пользователя на оплату. Kind.Subscription — оплата подписки за период (квартал/полгода/год,
/// Period задан), продлевает BillingPaidUntil при подтверждении. Kind.RoleChangeTopUp — доплата разницы
/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан), подтверждение НЕ меняет
/// BillingPaidUntil — см. RoleChangeTopUp.Compute и ConfirmPaymentRequestCommandHandler. Сумма
/// Period задан), продлевает BillingPaidUntil при подтверждении. Kind.PlanChangeTopUp — доплата разницы
/// в цене при увеличении тарифа с активным оплаченным периодом (Period не задан), подтверждение НЕ меняет
/// BillingPaidUntil — см. PlanChangeTopUp.Compute и ConfirmPaymentRequestCommandHandler. Сумма
/// замораживается на момент создания (по действовавшей на тот момент ставке PricingSettings) —
/// последующее изменение прайса админом не меняет уже созданные заявки. Не более одной активной
/// (AwaitingPayment/AwaitingConfirmation) заявки Kind.Subscription на пользователя — инвариант
/// проверяется на уровне Application; RoleChangeTopUp создаётся системой при одобрении заявки на роль
/// и этим инвариантом не ограничен.
/// проверяется на уровне Application; PlanChangeTopUp создаётся системой при самостоятельной смене
/// тарифа и этим инвариантом не ограничен.
/// </summary>
public sealed class PaymentRequest : Entity
{
@@ -42,13 +42,13 @@ public sealed class PaymentRequest : Entity
};
}
public static PaymentRequest CreateRoleChangeTopUp(Guid userId, int amountSnapshot)
public static PaymentRequest CreatePlanChangeTopUp(Guid userId, int amountSnapshot)
{
return new PaymentRequest
{
Id = Guid.NewGuid(),
UserId = userId,
Kind = PaymentRequestKind.RoleChangeTopUp,
Kind = PaymentRequestKind.PlanChangeTopUp,
Period = null,
AmountSnapshot = amountSnapshot,
Status = PaymentRequestStatus.AwaitingPayment,
@@ -1,10 +1,10 @@
namespace PnvPanel.Domain.Billing;
/// <summary>Subscription — обычная оплата за период (Period задан). RoleChangeTopUp — доплата разницы
/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан, не продлевает
/// BillingPaidUntil) — см. RoleChangeTopUp.Compute.</summary>
/// <summary>Subscription — обычная оплата за период (Period задан). PlanChangeTopUp — доплата разницы
/// в цене при увеличении тарифа с активным оплаченным периодом (Period не задан, не продлевает
/// BillingPaidUntil) — см. PlanChangeTopUp.Compute.</summary>
public enum PaymentRequestKind
{
Subscription,
RoleChangeTopUp,
PlanChangeTopUp,
}
@@ -0,0 +1,54 @@
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Domain.Billing;
/// <summary>
/// Проратированная доплата при увеличении тарифа (Plan/кастомное количество конфигов) с активным
/// оплаченным периодом: пользователь платит разницу в месячной стоимости между старым и новым
/// количеством конфигов за оставшиеся дни BillingPaidUntil, а не выкупает период заново — так
/// больший тариф не достаётся бесплатно до конца уже оплаченного периода. Базовая ставка —
/// PricePerConfigPerQuarter (минимальный/справочный тариф), с той же скидочной лесенкой
/// (PricingDiscountTier), что и обычная оплата — см. domain-model.md#planchangetopup.
/// </summary>
public static class PlanChangeTopUp
{
private const int DaysPerMonth = 30;
/// <summary>Null, если доплата не нужна: тариф не подорожал, оплаченный период уже истёк, или
/// у старого/нового количества конфигов нет квоты (unlimited — цена для неё не считается).</summary>
public static int? Compute(
int pricePerConfigPerMonth,
int oldConfigCount,
int newConfigCount,
IReadOnlyCollection<PricingDiscountTier> discountTiers,
DateTimeOffset paidUntil,
DateTimeOffset now
)
{
if (oldConfigCount < 0 || newConfigCount < 0)
return null;
var remainingDays = (paidUntil - now).TotalDays;
if (remainingDays <= 0)
return null;
var oldMonthly = MonthlyTotal(pricePerConfigPerMonth, oldConfigCount, discountTiers);
var newMonthly = MonthlyTotal(pricePerConfigPerMonth, newConfigCount, discountTiers);
if (newMonthly <= oldMonthly)
return null;
var amount = (newMonthly - oldMonthly) / (decimal)DaysPerMonth * (decimal)remainingDays;
return (int)Math.Round(amount, MidpointRounding.AwayFromZero);
}
private static int MonthlyTotal(
int pricePerConfigPerMonth,
int configCount,
IReadOnlyCollection<PricingDiscountTier> tiers
)
{
var raw = pricePerConfigPerMonth * configCount;
var discountPercent = PricingDiscount.ResolvePercent(tiers, configCount);
return PricingDiscount.Apply(raw, discountPercent);
}
}
@@ -1,53 +0,0 @@
using PnvPanel.Domain.Pricing;
namespace PnvPanel.Domain.Billing;
/// <summary>
/// Проратированная доплата при апгрейде роли с активным оплаченным периодом: пользователь платит
/// разницу в месячной стоимости между старой и новой ролью за оставшиеся дни BillingPaidUntil, а не
/// выкупает период заново — так более выгодная роль не достаётся бесплатно до конца уже оплаченного
/// периода. Базовая ставка — PricePerConfigPerQuarter (минимальный/справочный тариф), с той же
/// скидочной лесенкой (PricingDiscountTier), что и обычная оплата — см. domain-model.md#rolechangetopup.
/// </summary>
public static class RoleChangeTopUp
{
private const int DaysPerMonth = 30;
/// <summary>Null, если доплата не нужна: роль не подорожала, оплаченный период уже истёк, или
/// у старой/новой роли нет квоты (unlimited — цена для неё не считается).</summary>
public static int? Compute(
int pricePerConfigPerMonth,
int oldMaxConfigs,
int newMaxConfigs,
IReadOnlyCollection<PricingDiscountTier> discountTiers,
DateTimeOffset paidUntil,
DateTimeOffset now
)
{
if (oldMaxConfigs < 0 || newMaxConfigs < 0)
return null;
var remainingDays = (paidUntil - now).TotalDays;
if (remainingDays <= 0)
return null;
var oldMonthly = MonthlyTotal(pricePerConfigPerMonth, oldMaxConfigs, discountTiers);
var newMonthly = MonthlyTotal(pricePerConfigPerMonth, newMaxConfigs, discountTiers);
if (newMonthly <= oldMonthly)
return null;
var amount = (newMonthly - oldMonthly) / (decimal)DaysPerMonth * (decimal)remainingDays;
return (int)Math.Round(amount, MidpointRounding.AwayFromZero);
}
private static int MonthlyTotal(
int pricePerConfigPerMonth,
int maxConfigs,
IReadOnlyCollection<PricingDiscountTier> tiers
)
{
var raw = pricePerConfigPerMonth * maxConfigs;
var discountPercent = PricingDiscount.ResolvePercent(tiers, maxConfigs);
return PricingDiscount.Apply(raw, discountPercent);
}
}
+36
View File
@@ -0,0 +1,36 @@
using PnvPanel.Domain.Common;
namespace PnvPanel.Domain.Plans;
/// <summary>Тариф — каталог квот конфигов, из которого пользователь сам выбирает себе квоту без
/// подтверждения админа (см. ChangePlanCommand). Ведёт админ. Не поддерживает "безлимит" — это
/// доступно только через прямой оверрайд AppUser.ConfigQuota админом (AdminSetUserPlanCommand).</summary>
public sealed class Plan : Entity
{
public string Name { get; private set; } = string.Empty;
public int ConfigCount { get; private set; }
public int SortOrder { get; private set; }
public bool IsEnabled { get; private set; }
private Plan() { }
public static Plan Create(string name, int configCount, int sortOrder)
{
return new Plan
{
Id = Guid.NewGuid(),
Name = name,
ConfigCount = configCount,
SortOrder = sortOrder,
IsEnabled = true,
};
}
public void Update(string name, int configCount, int sortOrder, bool isEnabled)
{
Name = name;
ConfigCount = configCount;
SortOrder = sortOrder;
IsEnabled = isEnabled;
}
}
@@ -4,22 +4,17 @@ using PnvPanel.Domain.Exceptions;
namespace PnvPanel.Domain.Support;
/// <summary>
/// Обращение в поддержку: баг/предложение (свободная форма) либо заявка на роль (существующая роль
/// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не
/// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig).
/// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками,
/// а не runtime-проверкой одного универсального конструктора. Для ExtensionRequest (продление
/// оплаченного периода — только для billing-ролей, см. AppRole.BillingEnabled) заполнен RequestedDays.
/// Обращение в поддержку: баг/предложение (свободная форма) либо заявка на продление оплаченного
/// периода. Текст обращения и переписка — в TicketComment, отдельной таблицей (не навигационная
/// коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig). Для
/// ExtensionRequest (продление оплаченного периода — только для billing-ролей, см.
/// AppRole.BillingEnabled) заполнен RequestedDays.
/// </summary>
public sealed class SupportTicket : Entity
{
public Guid UserId { get; private set; }
public TicketType Type { get; private set; }
public TicketStatus Status { get; private set; }
public Guid? RequestedRoleId { get; private set; }
public string? ProposedRoleName { get; private set; }
public int? ProposedMaxConfigs { get; private set; }
public int? ProposedMaxIpLimit { get; private set; }
public int? RequestedDays { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
@@ -37,39 +32,6 @@ public sealed class SupportTicket : Entity
};
}
public static SupportTicket CreateRoleRequestForExistingRole(Guid userId, Guid roleId)
{
return new SupportTicket
{
Id = Guid.NewGuid(),
UserId = userId,
Type = TicketType.RoleRequest,
Status = TicketStatus.Open,
RequestedRoleId = roleId,
CreatedAt = DateTimeOffset.UtcNow,
};
}
public static SupportTicket CreateRoleRequestForNewRole(
Guid userId,
string name,
int maxConfigs,
int maxIpLimit
)
{
return new SupportTicket
{
Id = Guid.NewGuid(),
UserId = userId,
Type = TicketType.RoleRequest,
Status = TicketStatus.Open,
ProposedRoleName = name,
ProposedMaxConfigs = maxConfigs,
ProposedMaxIpLimit = maxIpLimit,
CreatedAt = DateTimeOffset.UtcNow,
};
}
public static SupportTicket CreateExtensionRequest(Guid userId, int requestedDays)
{
return new SupportTicket
@@ -83,7 +45,7 @@ public sealed class SupportTicket : Entity
};
}
/// <summary>Решено (в т.ч. заявка на роль одобрена — роль выдаётся оркестрацией на уровне Application).</summary>
/// <summary>Решено (в т.ч. заявка на продление одобрена — дни начисляются оркестрацией на уровне Application).</summary>
public void Resolve()
{
if (Status != TicketStatus.Open)
@@ -92,7 +54,7 @@ public sealed class SupportTicket : Entity
Status = TicketStatus.Resolved;
}
/// <summary>Финальное состояние — обратного пути нет (в т.ч. заявка на роль отклонена).</summary>
/// <summary>Финальное состояние — обратного пути нет (в т.ч. заявка на продление отклонена).</summary>
public void Close()
{
if (Status == TicketStatus.Closed)
@@ -3,6 +3,5 @@ namespace PnvPanel.Domain.Support;
public enum TicketType
{
BugReport,
RoleRequest,
ExtensionRequest,
}
@@ -60,8 +60,8 @@ public sealed class BillingService(
// отклонит, но и не просто "ничего не делаем": держим клиента рабочим на панели (Xray
// проверяет expiryTime сам, независимо от нашего статуса) — см.
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler и
// BillingConfigResumer.ProtectPendingConfigsAsync. RoleChangeTopUp намеренно не учитывается
// здесь — это доплата за апгрейд роли, а не оплата подписки, её ожидание не должно
// BillingConfigResumer.ProtectPendingConfigsAsync. PlanChangeTopUp намеренно не учитывается
// здесь — это доплата за увеличение тарифа, а не оплата подписки, её ожидание не должно
// спасать от приостановки за реально просроченную подписку.
var hasAwaitingConfirmation = await dbContext.PaymentRequests.AnyAsync(
r =>
@@ -7,11 +7,13 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Plans;
using PnvPanel.Infrastructure.Apps;
using PnvPanel.Infrastructure.BackgroundJobs;
using PnvPanel.Infrastructure.Identity;
using PnvPanel.Infrastructure.Instructions;
using PnvPanel.Infrastructure.Persistence;
using PnvPanel.Infrastructure.Plans;
using PnvPanel.Infrastructure.Pricing;
using PnvPanel.Infrastructure.Security;
using PnvPanel.Infrastructure.Storage;
@@ -68,6 +70,7 @@ public static class DependencyInjection
configuration.GetSection(AdminSeedOptions.SectionName)
);
services.Configure<RolesOptions>(configuration.GetSection(RolesOptions.SectionName));
services.Configure<PlansOptions>(configuration.GetSection(PlansOptions.SectionName));
var jwtOptions =
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
@@ -141,6 +144,7 @@ public static class DependencyInjection
services.AddScoped<IClientAppCatalogSeeder, ClientAppCatalogSeeder>();
services.AddScoped<IInstructionIntroSeeder, InstructionIntroSeeder>();
services.AddScoped<IPricingSettingsSeeder, PricingSettingsSeeder>();
services.AddScoped<IPlanSeeder, PlanSeeder>();
// Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как
// ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService).
services.AddScoped<CurrentUser>();
@@ -3,17 +3,15 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Infrastructure.Identity;
/// <summary>Роль с квотой на число конфигов и лимитом одновременных IP на клиента. У пользователя ровно одна роль.</summary>
/// <summary>Роль с лимитом одновременных IP на клиента, доступом к инбаундам и флагом биллинга.
/// Квота конфигов больше не на роли — см. AppUser.ConfigQuota/Plan. У пользователя ровно одна роль.</summary>
public class AppRole : IdentityRole<Guid>
{
public const int UnlimitedMaxConfigs = RoleQuota.Unlimited;
public const int UnlimitedMaxIpLimit = RoleQuota.Unlimited;
public int MaxConfigs { get; set; }
/// <summary>Лимит одновременных IP на клиента в 3x-ui (`limitIp`); -1 — без лимита. Применяется
/// только к новым клиентам, создаваемым в 3x-ui (см. IXuiPanelGateway.AddClientAsync) — при смене
/// роли/лимита существующие клиенты в панели не трогаются (как и квота MaxConfigs).</summary>
/// роли/лимита существующие клиенты в панели не трогаются (как и квота ConfigQuota).</summary>
public int MaxIpLimit { get; set; }
public bool IsSystem { get; set; }
@@ -15,6 +15,15 @@ public class AppUser : IdentityUser<Guid>
/// <summary>Блокировка админом: вход запрещён, все конфиги отключаются в 3x-ui (см. BlockUserCommandHandler).</summary>
public bool IsBlocked { get; set; }
/// <summary>Фактическая квота активных конфигов (замена AppRole.MaxConfigs); -1 — без лимита
/// (используется для admin). Меняется через ChangePlanCommand (самообслуживание) или
/// AdminSetUserPlanCommand.</summary>
public int ConfigQuota { get; set; }
/// <summary>Какой каталожный Plan выбран последним; null, если квота задана вручную (кастомное
/// число) или прямым оверрайдом админа. Обычная колонка без FK — по конвенции проекта.</summary>
public Guid? PlanId { get; set; }
/// <summary>Секрет для агрегированной подписки /sub/{token} (все активные конфиги пользователя).</summary>
public string SubscriptionToken { get; set; } = string.Empty;
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Infrastructure.Identity;
@@ -13,6 +14,7 @@ public sealed class DbInitializer(
IClientAppCatalogSeeder clientAppCatalogSeeder,
IInstructionIntroSeeder instructionIntroSeeder,
IPricingSettingsSeeder pricingSettingsSeeder,
IPlanSeeder planSeeder,
IOptions<AdminSeedOptions> adminSeedOptions,
IOptions<RolesOptions> rolesOptions,
ILogger<DbInitializer> logger
@@ -20,15 +22,9 @@ public sealed class DbInitializer(
{
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
await EnsureRoleAsync(
RoleNames.Admin,
AppRole.UnlimitedMaxConfigs,
AppRole.UnlimitedMaxIpLimit,
isSystem: true
);
await EnsureRoleAsync(RoleNames.Admin, AppRole.UnlimitedMaxIpLimit, isSystem: true);
await EnsureRoleAsync(
RoleNames.User,
rolesOptions.Value.DefaultUserMaxConfigs,
rolesOptions.Value.DefaultUserMaxIpLimit,
isSystem: true
);
@@ -36,19 +32,15 @@ public sealed class DbInitializer(
await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken);
await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken);
await pricingSettingsSeeder.SeedIfEmptyAsync(cancellationToken);
await planSeeder.SeedIfEmptyAsync(cancellationToken);
}
private async Task EnsureRoleAsync(string name, int maxConfigs, int maxIpLimit, bool isSystem)
private async Task EnsureRoleAsync(string name, int maxIpLimit, bool isSystem)
{
if (await roleManager.RoleExistsAsync(name))
return;
var role = new AppRole(name)
{
MaxConfigs = maxConfigs,
MaxIpLimit = maxIpLimit,
IsSystem = isSystem,
};
var role = new AppRole(name) { MaxIpLimit = maxIpLimit, IsSystem = isSystem };
var result = await roleManager.CreateAsync(role);
if (!result.Succeeded)
{
@@ -82,6 +74,7 @@ public sealed class DbInitializer(
UserName = options.Username,
IsActivated = true,
ActivatedAt = DateTimeOffset.UtcNow,
ConfigQuota = RoleQuota.Unlimited,
SubscriptionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)),
};
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
@@ -12,7 +13,8 @@ internal sealed class IdentityService(
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
RoleManager<AppRole> roleManager,
AppDbContext dbContext
AppDbContext dbContext,
IOptions<RolesOptions> rolesOptions
) : IIdentityService
{
public async Task<Result<Guid>> CreateUserAsync(
@@ -25,6 +27,7 @@ internal sealed class IdentityService(
{
UserName = userName,
IsActivated = false,
ConfigQuota = rolesOptions.Value.DefaultUserMaxConfigs,
SubscriptionToken = GenerateSubscriptionToken(),
};
var createResult = await userManager.CreateAsync(user, password);
@@ -91,7 +94,8 @@ internal sealed class IdentityService(
role.Name!,
user.IsActivated,
user.IsBlocked,
role.MaxConfigs,
user.ConfigQuota,
user.PlanId,
role.MaxIpLimit,
user.SubscriptionToken,
role.BillingEnabled,
@@ -492,6 +496,23 @@ internal sealed class IdentityService(
return Result.Success();
}
public async Task<Result> SetConfigQuotaAsync(
Guid userId,
int configQuota,
Guid? planId,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(AuthErrors.Unauthorized);
user.ConfigQuota = configQuota;
user.PlanId = planId;
await userManager.UpdateAsync(user);
return Result.Success();
}
private async Task<string> GetPrimaryRoleNameAsync(AppUser user)
{
var roles = await userManager.GetRolesAsync(user);
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PnvPanel.Application.Admin.Roles;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Interfaces;
@@ -11,12 +12,12 @@ namespace PnvPanel.Infrastructure.Identity;
internal sealed class RoleService(
RoleManager<AppRole> roleManager,
UserManager<AppUser> userManager,
IAppDbContext dbContext
IAppDbContext dbContext,
IOptions<RolesOptions> rolesOptions
) : IRoleService
{
public async Task<Result<RoleDto>> CreateRoleAsync(
string name,
int maxConfigs,
int maxIpLimit,
bool billingEnabled,
CancellationToken cancellationToken
@@ -30,7 +31,6 @@ internal sealed class RoleService(
var role = new AppRole(name)
{
MaxConfigs = maxConfigs,
MaxIpLimit = maxIpLimit,
IsSystem = false,
BillingEnabled = billingEnabled,
@@ -51,7 +51,6 @@ internal sealed class RoleService(
public async Task<Result<RoleDto>> UpdateRoleAsync(
Guid roleId,
int maxConfigs,
int maxIpLimit,
bool billingEnabled,
CancellationToken cancellationToken
@@ -69,7 +68,6 @@ internal sealed class RoleService(
var billingJustEnabled = billingEnabled && !role.BillingEnabled;
role.MaxConfigs = maxConfigs;
role.MaxIpLimit = maxIpLimit;
role.BillingEnabled = billingEnabled;
await roleManager.UpdateAsync(role);
@@ -137,14 +135,7 @@ 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,
r.BillingEnabled
))
.Select(r => new RoleDto(r.Id, r.Name!, r.MaxIpLimit, r.IsSystem, r.BillingEnabled))
.ToListAsync(cancellationToken);
}
@@ -178,6 +169,22 @@ internal sealed class RoleService(
await userManager.AddToRoleAsync(user, role.Name!);
// Безлимитная квота конфигов (-1) зарезервирована за admin (см. domain-model.md#approle) —
// выдаём её автоматически при назначении admin и снимаем при уходе с admin, иначе бывший
// админ молча остался бы с безлимитом навсегда.
if (staysAdmin && !wasAdmin)
{
user.ConfigQuota = RoleQuota.Unlimited;
user.PlanId = null;
await userManager.UpdateAsync(user);
}
else if (wasAdmin && !staysAdmin && user.ConfigQuota == RoleQuota.Unlimited)
{
user.ConfigQuota = rolesOptions.Value.DefaultUserMaxConfigs;
user.PlanId = null;
await userManager.UpdateAsync(user);
}
if (role.BillingEnabled)
await InitializeBillingGraceAsync([user], cancellationToken);
@@ -185,5 +192,5 @@ internal sealed class RoleService(
}
private static RoleDto ToDto(AppRole role) =>
new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem, role.BillingEnabled);
new(role.Id, role.Name!, role.MaxIpLimit, role.IsSystem, role.BillingEnabled);
}
@@ -10,6 +10,7 @@ using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions;
using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Plans;
using PnvPanel.Domain.Pricing;
using PnvPanel.Domain.Support;
using PnvPanel.Domain.Telegram;
@@ -65,6 +66,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<PaymentRequest> PaymentRequests => Set<PaymentRequest>();
public DbSet<Plan> Plans => Set<Plan>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PnvPanel.Domain.Plans;
namespace PnvPanel.Infrastructure.Persistence.Configurations;
public class PlanConfiguration : IEntityTypeConfiguration<Plan>
{
public void Configure(EntityTypeBuilder<Plan> builder)
{
builder.ToTable("Plans");
builder.HasKey(x => x.Id);
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
}
}
@@ -13,7 +13,6 @@ public class SupportTicketConfiguration : IEntityTypeConfiguration<SupportTicket
builder.Property(x => x.Type).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.ProposedRoleName).HasMaxLength(100);
builder.HasIndex(x => new { x.UserId, x.Status });
builder.HasIndex(x => new { x.Type, x.Status });
@@ -0,0 +1,128 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPlansAndUserConfigQuota : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProposedMaxConfigs",
table: "SupportTickets");
migrationBuilder.DropColumn(
name: "ProposedMaxIpLimit",
table: "SupportTickets");
migrationBuilder.DropColumn(
name: "ProposedRoleName",
table: "SupportTickets");
migrationBuilder.DropColumn(
name: "RequestedRoleId",
table: "SupportTickets");
migrationBuilder.AddColumn<int>(
name: "ConfigQuota",
table: "AspNetUsers",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<Guid>(
name: "PlanId",
table: "AspNetUsers",
type: "uuid",
nullable: true);
// Перенос текущей квоты роли на пользователя — до дропа AspNetRoles.MaxConfigs ниже,
// иначе исходные данные для переноса будут потеряны. У пользователя ровно одна роль
// (инвариант проекта) — джойн даёт ровно одну строку на юзера.
migrationBuilder.Sql(
"""
UPDATE "AspNetUsers" u
SET "ConfigQuota" = COALESCE(r."MaxConfigs", 3)
FROM "AspNetUserRoles" ur
JOIN "AspNetRoles" r ON r."Id" = ur."RoleId"
WHERE ur."UserId" = u."Id";
""");
// PaymentRequestKind.RoleChangeTopUp переименован в PlanChangeTopUp (хранится как строка).
migrationBuilder.Sql(
"""
UPDATE "PaymentRequests" SET "Kind" = 'PlanChangeTopUp' WHERE "Kind" = 'RoleChangeTopUp';
""");
migrationBuilder.DropColumn(
name: "MaxConfigs",
table: "AspNetRoles");
migrationBuilder.CreateTable(
name: "Plans",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ConfigCount = table.Column<int>(type: "integer", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Plans", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Plans");
migrationBuilder.DropColumn(
name: "ConfigQuota",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "PlanId",
table: "AspNetUsers");
migrationBuilder.AddColumn<int>(
name: "ProposedMaxConfigs",
table: "SupportTickets",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ProposedMaxIpLimit",
table: "SupportTickets",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ProposedRoleName",
table: "SupportTickets",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "RequestedRoleId",
table: "SupportTickets",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "MaxConfigs",
table: "AspNetRoles",
type: "integer",
nullable: false,
defaultValue: 0);
}
}
}
@@ -590,6 +590,31 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.ToTable("Nodes", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Plans.Plan", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("ConfigCount")
.HasColumnType("integer");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Plans", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b =>
{
b.Property<Guid>("Id")
@@ -645,22 +670,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ProposedMaxConfigs")
.HasColumnType("integer");
b.Property<int?>("ProposedMaxIpLimit")
.HasColumnType("integer");
b.Property<string>("ProposedRoleName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int?>("RequestedDays")
.HasColumnType("integer");
b.Property<Guid?>("RequestedRoleId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
@@ -823,9 +835,6 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<int>("MaxConfigs")
.HasColumnType("integer");
b.Property<int>("MaxIpLimit")
.HasColumnType("integer");
@@ -874,6 +883,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<int>("ConfigQuota")
.HasColumnType("integer");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
@@ -910,6 +922,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<Guid?>("PlanId")
.HasColumnType("uuid");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Plans;
using PnvPanel.Infrastructure.Persistence;
namespace PnvPanel.Infrastructure.Plans;
internal sealed class PlanSeeder(AppDbContext dbContext, ILogger<PlanSeeder> logger) : IPlanSeeder
{
public async Task SeedIfEmptyAsync(CancellationToken cancellationToken)
{
if (await dbContext.Plans.AnyAsync(cancellationToken))
return;
dbContext.Plans.AddRange(
Plan.Create("Стандарт", 3, 0),
Plan.Create("Плюс", 6, 1),
Plan.Create("Про", 9, 2)
);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("Seeded default plans");
}
}
@@ -31,7 +31,8 @@ public class ConfirmPaymentRequestCommandHandlerTests
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: 5,
ConfigQuota: 5,
PlanId: null,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: true,
@@ -225,13 +226,13 @@ public class ConfirmPaymentRequestCommandHandlerTests
}
[Fact]
public async Task Handle_WhenRoleChangeTopUp_ConfirmsWithoutExtendingPaidUntil()
public async Task Handle_WhenPlanChangeTopUp_ConfirmsWithoutExtendingPaidUntil()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var userId = Guid.NewGuid();
var existingPaidUntil = DateTimeOffset.UtcNow.AddDays(20);
var request = PaymentRequest.CreateRoleChangeTopUp(userId, 1200);
var request = PaymentRequest.CreatePlanChangeTopUp(userId, 1200);
request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None);
@@ -28,7 +28,8 @@ public class GrantBillingGiftCommandHandlerTests
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: 5,
ConfigQuota: 5,
PlanId: null,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: billingEnabled,
@@ -17,7 +17,7 @@ public class ListPaymentRequestsQueryHandlerTests
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var subscription = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
var topUp = PaymentRequest.CreateRoleChangeTopUp(userId, 500);
var topUp = PaymentRequest.CreatePlanChangeTopUp(userId, 500);
dbContext.PaymentRequests.AddRange(subscription, topUp);
await dbContext.SaveChangesAsync(CancellationToken.None);
@@ -30,7 +30,7 @@ public class ListPaymentRequestsQueryHandlerTests
var result = await handler.Handle(
new ListPaymentRequestsQuery(
StatusFilter: null,
KindFilter: PaymentRequestKind.RoleChangeTopUp,
KindFilter: PaymentRequestKind.PlanChangeTopUp,
Search: null,
Page: 1,
PageSize: 20
@@ -42,7 +42,8 @@ public class RejectPaymentRequestCommandHandlerTests
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: 5,
ConfigQuota: 5,
PlanId: null,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: true,
@@ -72,8 +72,8 @@ public class FactoryResetCommandHandlerTests
.Returns(
new List<RoleDto>
{
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
new(customRoleId, "premium", 10, 5, IsSystem: false, BillingEnabled: false),
new(adminRoleId, "admin", -1, IsSystem: true, BillingEnabled: false),
new(customRoleId, "premium", 5, IsSystem: false, BillingEnabled: false),
}
);
_roleService
@@ -0,0 +1,129 @@
using PnvPanel.Application.Admin.Plans;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Plans;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Plans;
public class PlanCommandHandlerTests
{
[Fact]
public async Task Create_AddsPlan()
{
using var dbContext = InMemoryDbContextFactory.Create();
var handler = new CreatePlanCommandHandler(dbContext);
var result = await handler.Handle(
new CreatePlanCommand("Стандарт", 3, 0),
CancellationToken.None
);
await dbContext.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal("Стандарт", result.Value.Name);
Assert.Equal(3, result.Value.ConfigCount);
Assert.True(result.Value.IsEnabled);
Assert.NotNull(await dbContext.Plans.FindAsync([result.Value.Id], CancellationToken.None));
}
[Fact]
public async Task Update_WhenNotFound_ReturnsNotFound()
{
using var dbContext = InMemoryDbContextFactory.Create();
var handler = new UpdatePlanCommandHandler(dbContext);
var result = await handler.Handle(
new UpdatePlanCommand(Guid.NewGuid(), "Плюс", 6, 1, true),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(PnvPanel.Application.Plans.PlanErrors.NotFound, result.Error);
}
[Fact]
public async Task Update_WhenFound_UpdatesFields()
{
using var dbContext = InMemoryDbContextFactory.Create();
var plan = Plan.Create("Стандарт", 3, 0);
dbContext.Plans.Add(plan);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePlanCommandHandler(dbContext);
var result = await handler.Handle(
new UpdatePlanCommand(plan.Id, "Плюс", 6, 1, false),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal("Плюс", result.Value.Name);
Assert.Equal(6, result.Value.ConfigCount);
Assert.False(result.Value.IsEnabled);
}
[Fact]
public async Task Delete_WhenNotFound_ReturnsNotFound()
{
using var dbContext = InMemoryDbContextFactory.Create();
var handler = new DeletePlanCommandHandler(dbContext);
var result = await handler.Handle(
new DeletePlanCommand(Guid.NewGuid()),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(PnvPanel.Application.Plans.PlanErrors.NotFound, result.Error);
}
[Fact]
public async Task Delete_WhenFound_RemovesPlan()
{
using var dbContext = InMemoryDbContextFactory.Create();
var plan = Plan.Create("Стандарт", 3, 0);
dbContext.Plans.Add(plan);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new DeletePlanCommandHandler(dbContext);
var result = await handler.Handle(new DeletePlanCommand(plan.Id), CancellationToken.None);
await dbContext.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Null(await dbContext.Plans.FindAsync([plan.Id], CancellationToken.None));
}
[Fact]
public async Task ListAdmin_ReturnsAllPlansOrderedBySortOrder()
{
using var dbContext = InMemoryDbContextFactory.Create();
dbContext.Plans.AddRange(
Plan.Create("Про", 9, 2),
Plan.Create("Стандарт", 3, 0),
Plan.Create("Плюс", 6, 1)
);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new ListAdminPlansQueryHandler(dbContext);
var result = await handler.Handle(new ListAdminPlansQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(["Стандарт", "Плюс", "Про"], result.Value.Select(p => p.Name));
}
[Fact]
public async Task ListPublic_OnlyReturnsEnabledPlans()
{
using var dbContext = InMemoryDbContextFactory.Create();
var disabled = Plan.Create("Скрытый", 12, 3);
disabled.Update(disabled.Name, disabled.ConfigCount, disabled.SortOrder, isEnabled: false);
dbContext.Plans.AddRange(Plan.Create("Стандарт", 3, 0), disabled);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new PnvPanel.Application.Plans.ListPlansQueryHandler(dbContext);
var result = await handler.Handle(new PnvPanel.Application.Plans.ListPlansQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Single(result.Value);
Assert.Equal("Стандарт", result.Value[0].Name);
}
}
@@ -31,7 +31,8 @@ public class ApproveExtensionRequestCommandHandlerTests
"premium",
IsActivated: true,
IsBlocked: false,
MaxConfigs: 5,
ConfigQuota: 5,
PlanId: null,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: true,
@@ -1,265 +0,0 @@
using NSubstitute;
using PnvPanel.Application.Admin.Roles;
using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Support;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Support;
public class ApproveRoleRequestCommandHandlerTests
{
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
private ApproveRoleRequestCommandHandler CreateHandler(IAppDbContext dbContext, ICurrentUser currentUser) =>
new(dbContext, _roleService, _identityService, _notifier, _telegramNotifier, currentUser);
private static CurrentUserProfile Profile(
Guid userId,
int maxConfigs,
DateTimeOffset? billingPaidUntil = null
) =>
new(
userId,
"alice",
Guid.NewGuid(),
"old-role",
IsActivated: true,
IsBlocked: false,
MaxConfigs: maxConfigs,
MaxIpLimit: 3,
SubscriptionToken: "sub-token",
BillingEnabled: billingPaidUntil != null,
BillingPaidUntil: billingPaidUntil,
BillingSuspended: false
);
[Fact]
public async Task Handle_ForNewRoleRequest_CreatesRoleAssignsAndResolves()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForNewRole(userId, "premium", 10, 5);
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
var newRoleId = Guid.NewGuid();
_roleService
.CreateRoleAsync("premium", 10, 5, false, Arg.Any<CancellationToken>())
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false, false)));
_roleService
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>())
.Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(TicketStatus.Resolved, ticket.Status);
await _roleService
.Received(1)
.CreateRoleAsync("premium", 10, 5, false, Arg.Any<CancellationToken>());
await _roleService
.Received(1)
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>());
await _telegramNotifier
.Received(1)
.NotifyUserAsync(
userId,
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_ForExistingRoleRequest_SkipsRoleCreation()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 10, 5, false, false) });
_roleService
.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>())
.Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await _roleService
.DidNotReceive()
.CreateRoleAsync(
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<bool>(),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_WhenNotRoleRequestType_ReturnsError()
{
using var dbContext = InMemoryDbContextFactory.Create();
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(SupportErrors.NotRoleRequest, result.Error);
}
[Fact]
public async Task Handle_WhenTicketIsOwnedByAdmin_StillApproves()
{
// Одобрить свою же заявку можно — единственный реальный риск (снять admin с последнего
// администратора) ловит RoleService.ChangeUserRoleAsync, а не этот хендлер (см. соседний тест).
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForExistingRole(adminId, roleId);
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 10, 5, false, false) });
_roleService
.ChangeUserRoleAsync(adminId, roleId, Arg.Any<CancellationToken>())
.Returns(Result.Success());
var currentUser = FakeCurrentUser.Authenticated(adminId, "admin");
var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(TicketStatus.Resolved, ticket.Status);
}
[Fact]
public async Task Handle_WhenRoleServiceRefusesLastAdminDowngrade_PropagatesFailure()
{
using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForExistingRole(adminId, roleId);
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", 10, 5, false, false) });
_roleService
.ChangeUserRoleAsync(adminId, roleId, Arg.Any<CancellationToken>())
.Returns(Result.Failure(RoleErrors.CannotRemoveLastAdmin));
var currentUser = FakeCurrentUser.Authenticated(adminId, "admin");
var handler = CreateHandler(dbContext, currentUser);
var result = await handler.Handle(
new ApproveRoleRequestCommand(ticket.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(RoleErrors.CannotRemoveLastAdmin, result.Error);
// Тикет остаётся Open — можно повторить попытку после назначения второго админа.
Assert.Equal(TicketStatus.Open, ticket.Status);
}
[Fact]
public async Task Handle_WhenNewRoleMoreExpensiveWithActivePaidPeriod_CreatesRoleChangeTopUp()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
dbContext.SupportTickets.Add(ticket);
var pricing = PnvPanel.Domain.Pricing.PricingSettings.CreateDefault();
pricing.Update(500, 450, 400);
dbContext.PricingSettings.Add(pricing);
await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", MaxConfigs: 10, 5, false, BillingEnabled: true) });
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, maxConfigs: 3, billingPaidUntil: DateTimeOffset.UtcNow.AddDays(30)));
var handler = CreateHandler(dbContext, FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"));
var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None);
Assert.True(result.IsSuccess);
var topUp = Assert.Single(dbContext.PaymentRequests.Local);
Assert.Equal(PaymentRequestKind.RoleChangeTopUp, topUp.Kind);
Assert.Null(topUp.Period);
Assert.True(topUp.AmountSnapshot > 0);
}
[Fact]
public async Task Handle_WhenNoActivePaidPeriod_DoesNotCreateTopUp()
{
using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
dbContext.SupportTickets.Add(ticket);
await dbContext.SaveChangesAsync(CancellationToken.None);
_roleService
.ListRolesAsync(Arg.Any<CancellationToken>())
.Returns(new List<RoleDto> { new(roleId, "premium", MaxConfigs: 10, 5, false, BillingEnabled: true) });
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_identityService
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
.Returns(Profile(userId, maxConfigs: 3, billingPaidUntil: null));
var handler = CreateHandler(dbContext, FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"));
var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Empty(dbContext.PaymentRequests.Local);
}
}

Some files were not shown because too many files have changed in this diff Show More