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 AdminSeed__Password=change-me-strong-admin-password
# ── Роли по умолчанию ───────────────────────────────────────────────────── # ── Роли по умолчанию ─────────────────────────────────────────────────────
# Квота конфигов для системной роли "user" (выдаётся при регистрации). # Стартовая квота конфигов нового пользователя (AppUser.ConfigQuota при регистрации; далее меняется
# самостоятельно через страницу тарифа /plan, не привязана к роли).
Roles__DefaultUserMaxConfigs=3 Roles__DefaultUserMaxConfigs=3
# Лимит одновременных IP на клиента (limitIp в 3x-ui) для системной роли "user"; -1 = без лимита. # Лимит одновременных IP на клиента (limitIp в 3x-ui) для системной роли "user"; -1 = без лимита.
Roles__DefaultUserMaxIpLimit=2 Roles__DefaultUserMaxIpLimit=2
# ── Тарифы (самообслуживание) ────────────────────────────────────────────
# Верхняя граница ручного ввода количества конфигов при смене тарифа — защита от абьюза.
Plans__MaxCustomConfigCount=50
# Нижняя граница ручного ввода — не даёт занизить квоту ниже разумного минимума.
Plans__MinCustomConfigCount=3
# ── Rate limiting ──────────────────────────────────────────────────────── # ── Rate limiting ────────────────────────────────────────────────────────
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20. # Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20.
# RateLimiting__AuthPermitLimit=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) .MapPost("/tickets/{id:guid}/resolve", Resolve)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPost("/tickets/{id:guid}/close", Close).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 admin
.MapPost("/tickets/{id:guid}/approve-extension", ApproveExtensionRequest) .MapPost("/tickets/{id:guid}/approve-extension", ApproveExtensionRequest)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
@@ -102,30 +96,6 @@ public static class AdminSupportEndpoints
return result.ToHttpResult(); 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( private static async Task<IResult> ApproveExtensionRequest(
Guid id, Guid id,
ISender sender, ISender sender,
@@ -151,6 +121,4 @@ public static class AdminSupportEndpoints
} }
} }
public sealed record RejectRoleRequestBody(string? Reason);
public sealed record RejectExtensionRequestBody(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 admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole) .MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/plan", SetUserPlan)
.Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
@@ -53,7 +56,7 @@ public static class RoleEndpoints
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit, body.BillingEnabled), new UpdateRoleCommand(id, body.MaxIpLimit, body.BillingEnabled),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
@@ -82,8 +85,24 @@ public static class RoleEndpoints
); );
return result.ToHttpResult(); 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 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.AddComment;
using PnvPanel.Application.Support.CreateBugReport; using PnvPanel.Application.Support.CreateBugReport;
using PnvPanel.Application.Support.CreateExtensionRequest; using PnvPanel.Application.Support.CreateExtensionRequest;
using PnvPanel.Application.Support.CreateRoleRequest;
using PnvPanel.Application.Support.GetAttachment; using PnvPanel.Application.Support.GetAttachment;
using PnvPanel.Application.Support.GetSupportPricing; using PnvPanel.Application.Support.GetSupportPricing;
using PnvPanel.Application.Support.GetTicket; using PnvPanel.Application.Support.GetTicket;
using PnvPanel.Application.Support.ListMyTickets; using PnvPanel.Application.Support.ListMyTickets;
using PnvPanel.Application.Support.ListSelectableRoles;
using PnvPanel.Application.Support.Reopen; using PnvPanel.Application.Support.Reopen;
using PnvPanel.Domain.Support; using PnvPanel.Domain.Support;
@@ -24,13 +22,11 @@ public static class SupportEndpoints
{ {
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization(); var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
group.MapGet("/pricing", GetSupportPricing).Produces<PricingSettingsDto>(); group.MapGet("/pricing", GetSupportPricing).Produces<PricingSettingsDto>();
group group
.MapPost("/tickets/bug-reports", CreateBugReport) .MapPost("/tickets/bug-reports", CreateBugReport)
.DisableAntiforgery() .DisableAntiforgery()
.Produces<TicketDetailDto>(); .Produces<TicketDetailDto>();
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
group group
.MapPost("/tickets/extension-requests", CreateExtensionRequest) .MapPost("/tickets/extension-requests", CreateExtensionRequest)
.Produces<TicketDetailDto>(); .Produces<TicketDetailDto>();
@@ -46,15 +42,6 @@ public static class SupportEndpoints
return app; 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( private static async Task<IResult> GetSupportPricing(
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -76,23 +63,6 @@ public static class SupportEndpoints
return result.ToHttpResult(); 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( private static async Task<IResult> CreateExtensionRequest(
CreateExtensionRequestBody body, CreateExtensionRequestBody body,
ISender sender, 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 CreateExtensionRequestBody(int RequestedDays, string Justification);
public sealed record ListTicketsRequest( public sealed record ListTicketsRequest(
+2
View File
@@ -184,11 +184,13 @@ app.MapInboundEndpoints();
app.MapConfigEndpoints(); app.MapConfigEndpoints();
app.MapSubscriptionEndpoints(); app.MapSubscriptionEndpoints();
app.MapAppEndpoints(); app.MapAppEndpoints();
app.MapPlanEndpoints();
app.MapNewsEndpoints(); app.MapNewsEndpoints();
app.MapInstructionEndpoints(); app.MapInstructionEndpoints();
app.MapAdminUserEndpoints(); app.MapAdminUserEndpoints();
app.MapAdminStatsEndpoints(); app.MapAdminStatsEndpoints();
app.MapAdminAppEndpoints(); app.MapAdminAppEndpoints();
app.MapAdminPlanEndpoints();
app.MapAdminNewsEndpoints(); app.MapAdminNewsEndpoints();
app.MapAdminInstructionEndpoints(); app.MapAdminInstructionEndpoints();
app.MapAdminPricingEndpoints(); app.MapAdminPricingEndpoints();
@@ -440,55 +440,6 @@ public sealed class PnvBotUpdateHandler(
break; 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": case "erq":
{ {
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) 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( public async Task NotifyAdminsExtensionRequestCreatedAsync(
Guid ticketId, Guid ticketId,
string userName, string userName,
@@ -196,8 +155,7 @@ internal sealed class TelegramNotifier(
if (string.IsNullOrWhiteSpace(options.Value.BotToken)) if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return; return;
var typeLabel = type == TicketType.RoleRequest ? "заявка на роль" : "баг/предложение"; var text = $"🔓 Тикет от <b>{Escape(userName)}</b> переоткрыт (баг/предложение)";
var text = $"🔓 Тикет от <b>{Escape(userName)}</b> переоткрыт ({typeLabel})";
InlineKeyboardMarkup? keyboard = null; InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
@@ -280,7 +238,7 @@ internal sealed class TelegramNotifier(
if (string.IsNullOrWhiteSpace(options.Value.BotToken)) if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return; 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 text = $"💰 <b>{Escape(userName)}</b> заявляет о {reason} — {amount} ₽\nПроверьте поступление и подтвердите.";
var keyboard = new InlineKeyboardMarkup( var keyboard = new InlineKeyboardMarkup(
@@ -48,7 +48,7 @@ public sealed class ConfirmPaymentRequestCommandHandler(
var (request, newPaidUntil) = claimed.Value; var (request, newPaidUntil) = claimed.Value;
// RoleChangeTopUp — доплата разницы в цене при апгрейде роли, не покупка времени: подтверждение // PlanChangeTopUp — доплата разницы в цене при увеличении тарифа, не покупка времени: подтверждение
// не возвращает Expired-конфиги (это делает обычная Subscription-оплата/продление). // не возвращает Expired-конфиги (это делает обычная Subscription-оплата/продление).
if (request.Kind == PaymentRequestKind.Subscription) if (request.Kind == PaymentRequestKind.Subscription)
{ {
@@ -75,8 +75,8 @@ public sealed class ConfirmPaymentRequestCommandHandler(
); );
var message = var message =
request.Kind == PaymentRequestKind.RoleChangeTopUp request.Kind == PaymentRequestKind.PlanChangeTopUp
? "✅ Доплата за смену роли подтверждена." ? "✅ Доплата за смену тарифа подтверждена."
: $"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}."; : $"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.";
await telegramNotifier.NotifyUserAsync(request.UserId, message, null, cancellationToken); await telegramNotifier.NotifyUserAsync(request.UserId, message, null, cancellationToken);
@@ -105,7 +105,7 @@ public sealed class ConfirmPaymentRequestCommandHandler(
) )
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotDecidable); return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotDecidable);
if (request.Kind == PaymentRequestKind.RoleChangeTopUp) if (request.Kind == PaymentRequestKind.PlanChangeTopUp)
{ {
request.Confirm(adminId); request.Confirm(adminId);
return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, null)); 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; namespace PnvPanel.Application.Admin.Roles;
public sealed record CreateRoleCommand( public sealed record CreateRoleCommand(string Name, int MaxIpLimit, bool BillingEnabled)
string Name, : ICommand<Result<RoleDto>>;
int MaxConfigs,
int MaxIpLimit,
bool BillingEnabled
) : ICommand<Result<RoleDto>>;
@@ -13,7 +13,6 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService)
) => ) =>
roleService.CreateRoleAsync( roleService.CreateRoleAsync(
command.Name, command.Name,
command.MaxConfigs,
command.MaxIpLimit, command.MaxIpLimit,
command.BillingEnabled, command.BillingEnabled,
cancellationToken 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.Name).NotEmpty().Length(2, 32).Matches("^[a-zA-Z0-9_-]+$");
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1); RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
} }
} }
@@ -4,9 +4,5 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles; namespace PnvPanel.Application.Admin.Roles;
public sealed record UpdateRoleCommand( public sealed record UpdateRoleCommand(Guid RoleId, int MaxIpLimit, bool BillingEnabled)
Guid RoleId, : ICommand<Result<RoleDto>>;
int MaxConfigs,
int MaxIpLimit,
bool BillingEnabled
) : ICommand<Result<RoleDto>>;
@@ -13,7 +13,6 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService)
) => ) =>
roleService.UpdateRoleAsync( roleService.UpdateRoleAsync(
command.RoleId, command.RoleId,
command.MaxConfigs,
command.MaxIpLimit, command.MaxIpLimit,
command.BillingEnabled, command.BillingEnabled,
cancellationToken cancellationToken
@@ -6,7 +6,6 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCom
{ {
public UpdateRoleCommandValidator() public UpdateRoleCommandValidator()
{ {
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
RuleFor(x => x.MaxIpLimit).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; namespace PnvPanel.Application.Admin.Support;
public sealed class GetTicketAdminQueryHandler( public sealed class GetTicketAdminQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
IAppDbContext dbContext, : IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
IIdentityService identityService,
IRoleService roleService
) : IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
{ {
public async Task<Result<TicketDetailDto>> Handle( public async Task<Result<TicketDetailDto>> Handle(
GetTicketAdminQuery query, GetTicketAdminQuery query,
@@ -26,7 +23,6 @@ public sealed class GetTicketAdminQueryHandler(
var dto = await TicketMapping.ToDetailDtoAsync( var dto = await TicketMapping.ToDetailDtoAsync(
dbContext, dbContext,
identityService, identityService,
roleService,
ticket, ticket,
cancellationToken cancellationToken
); );
@@ -10,8 +10,9 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
/// <summary>Проверка статуса + Close() — под AdvisoryLock (по Id тикета), см. /// <summary>Проверка статуса + Close() — под AdvisoryLock (по Id тикета) — без него отклонение с
/// ApproveRoleRequestCommandHandler для полного обоснования.</summary> /// сайта, гонящееся с отклонением из Telegram по одной и той же заявке, могли бы оба пройти
/// проверку статуса и оба закрыть тикет.</summary>
public sealed class RejectExtensionRequestCommandHandler( public sealed class RejectExtensionRequestCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IRealtimeNotifier notifier, 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) if (!profile.BillingEnabled)
return Result.Failure<PaymentRequestDto>(BillingErrors.NotEnabled); return Result.Failure<PaymentRequestDto>(BillingErrors.NotEnabled);
if (profile.MaxConfigs == RoleQuota.Unlimited) if (profile.ConfigQuota == RoleQuota.Unlimited)
return Result.Failure<PaymentRequestDto>(BillingErrors.UnlimitedRoleNotSupported); return Result.Failure<PaymentRequestDto>(BillingErrors.UnlimitedRoleNotSupported);
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
@@ -54,16 +54,16 @@ public sealed class CreatePaymentRequestCommandHandler(
.PricingDiscountTiers.AsNoTracking() .PricingDiscountTiers.AsNoTracking()
.Where(t => t.PricingSettingsId == pricing.Id) .Where(t => t.PricingSettingsId == pricing.Id)
.ToListAsync(cancellationToken); .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( var claimed = await AdvisoryLock.RunAsync(
dbContext, dbContext,
userId, userId,
async lockedCancellationToken => async lockedCancellationToken =>
{ {
// RoleChangeTopUp не считается активной подписной заявкой — доплата за смену роли не // PlanChangeTopUp не считается активной подписной заявкой — доплата за смену тарифа не
// должна мешать пользователю продлить/оформить обычную подписку. // должна мешать пользователю продлить/оформить обычную подписку.
var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync( var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync(
r => r =>
@@ -40,7 +40,7 @@ public sealed class MarkPaymentSentCommandHandler(
// Оплаченный период уже мог истечь, пока пользователь собирался заплатить — не ждём часовой // Оплаченный период уже мог истечь, пока пользователь собирался заплатить — не ждём часовой
// тик BillingService, сразу держим клиента рабочим на панели на время рассмотрения заявки // тик BillingService, сразу держим клиента рабочим на панели на время рассмотрения заявки
// (см. BillingConfigResumer.ProtectPendingConfigsAsync). Только для Subscription — доплата за // (см. BillingConfigResumer.ProtectPendingConfigsAsync). Только для Subscription — доплата за
// смену роли (RoleChangeTopUp) на приостановку не влияет. // смену тарифа (PlanChangeTopUp) на приостановку не влияет.
if ( if (
request.Kind == PaymentRequestKind.Subscription request.Kind == PaymentRequestKind.Subscription
&& (profile?.BillingPaidUntil is null || profile.BillingPaidUntil < DateTimeOffset.UtcNow) && (profile?.BillingPaidUntil is null || profile.BillingPaidUntil < DateTimeOffset.UtcNow)
@@ -46,7 +46,7 @@ public sealed class SendRequisitesToTelegramCommandHandler(
} }
private static string DescribeRequest(PaymentRequest request) => 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) => private static string PeriodLabel(PaymentPeriod period) =>
period switch period switch
@@ -9,6 +9,7 @@ using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions; using PnvPanel.Domain.Instructions;
using PnvPanel.Domain.News; using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Plans;
using PnvPanel.Domain.Pricing; using PnvPanel.Domain.Pricing;
using PnvPanel.Domain.Support; using PnvPanel.Domain.Support;
using PnvPanel.Domain.Telegram; using PnvPanel.Domain.Telegram;
@@ -55,6 +56,8 @@ public interface IAppDbContext
DbSet<PaymentRequest> PaymentRequests { get; } DbSet<PaymentRequest> PaymentRequests { get; }
DbSet<Plan> Plans { get; }
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary> /// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
DatabaseFacade Database { get; } DatabaseFacade Database { get; }
@@ -11,7 +11,8 @@ public sealed record CurrentUserProfile(
string Role, string Role,
bool IsActivated, bool IsActivated,
bool IsBlocked, bool IsBlocked,
int MaxConfigs, int ConfigQuota,
Guid? PlanId,
int MaxIpLimit, int MaxIpLimit,
string SubscriptionToken, string SubscriptionToken,
bool BillingEnabled, bool BillingEnabled,
@@ -188,4 +189,14 @@ public interface IIdentityService
DateTimeOffset paidUntil, DateTimeOffset paidUntil,
CancellationToken cancellationToken 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; namespace PnvPanel.Application.Common.Interfaces;
public sealed record RoleDto( public sealed record RoleDto(Guid Id, string Name, int MaxIpLimit, bool IsSystem, bool BillingEnabled);
Guid Id,
string Name,
int MaxConfigs,
int MaxIpLimit,
bool IsSystem,
bool BillingEnabled
);
public interface IRoleService public interface IRoleService
{ {
Task<Result<RoleDto>> CreateRoleAsync( Task<Result<RoleDto>> CreateRoleAsync(
string name, string name,
int maxConfigs,
int maxIpLimit, int maxIpLimit,
bool billingEnabled, bool billingEnabled,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -23,7 +15,6 @@ public interface IRoleService
Task<Result<RoleDto>> UpdateRoleAsync( Task<Result<RoleDto>> UpdateRoleAsync(
Guid roleId, Guid roleId,
int maxConfigs,
int maxIpLimit, int maxIpLimit,
bool billingEnabled, bool billingEnabled,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -32,15 +32,6 @@ public interface ITelegramNotifier
CancellationToken cancellationToken CancellationToken cancellationToken
); );
/// <summary>Заявка на роль — инлайн-кнопки «Одобрить/Отклонить», решается полностью в Telegram.</summary>
Task NotifyAdminsRoleRequestCreatedAsync(
Guid ticketId,
string userName,
string roleDescription,
string justification,
CancellationToken cancellationToken
);
/// <summary>Рассылка о публикации новости всем активированным пользователям с привязанным Telegram /// <summary>Рассылка о публикации новости всем активированным пользователям с привязанным Telegram
/// (кнопка-ссылка на сайт, где новость показана целиком).</summary> /// (кнопка-ссылка на сайт, где новость показана целиком).</summary>
Task NotifyUsersNewsPublishedAsync(string title, CancellationToken cancellationToken); Task NotifyUsersNewsPublishedAsync(string title, CancellationToken cancellationToken);
@@ -55,8 +46,8 @@ public interface ITelegramNotifier
); );
/// <summary>Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается /// <summary>Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается
/// полностью в Telegram (аналогично заявке на роль). Period задан только для Kind.Subscription — /// полностью в Telegram. Period задан только для Kind.Subscription — для Kind.PlanChangeTopUp он
/// для Kind.RoleChangeTopUp он null (доплата не привязана к тарифному периоду).</summary> /// null (доплата не привязана к тарифному периоду).</summary>
Task NotifyAdminsPaymentRequestedAsync( Task NotifyAdminsPaymentRequestedAsync(
Guid requestId, Guid requestId,
string userName, string userName,
@@ -67,7 +58,7 @@ public interface ITelegramNotifier
); );
/// <summary>Заявка на продление оплаченного периода — инлайн-кнопки «Одобрить/Отклонить», решается /// <summary>Заявка на продление оплаченного периода — инлайн-кнопки «Одобрить/Отклонить», решается
/// полностью в Telegram (аналогично заявке на роль).</summary> /// полностью в Telegram.</summary>
Task NotifyAdminsExtensionRequestCreatedAsync( Task NotifyAdminsExtensionRequestCreatedAsync(
Guid ticketId, Guid ticketId,
string userName, string userName,
@@ -59,7 +59,7 @@ public sealed class CreateVpnConfigCommandHandler(
var reserveResult = await ReserveQuotaSlotAsync( var reserveResult = await ReserveQuotaSlotAsync(
userId, userId,
profile.MaxConfigs, profile.ConfigQuota,
config, config,
cancellationToken cancellationToken
); );
@@ -103,7 +103,7 @@ public sealed class CreateVpnConfigCommandHandler(
/// </summary> /// </summary>
private async Task<Result> ReserveQuotaSlotAsync( private async Task<Result> ReserveQuotaSlotAsync(
Guid userId, Guid userId,
int maxConfigs, int configQuota,
VpnConfig config, VpnConfig config,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
@@ -122,7 +122,7 @@ public sealed class CreateVpnConfigCommandHandler(
cancellationToken cancellationToken
); );
if (maxConfigs != RoleQuota.Unlimited && activeCount >= maxConfigs) if (configQuota != RoleQuota.Unlimited && activeCount >= configQuota)
{ {
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
return Result.Failure(ConfigErrors.QuotaExceeded); 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 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(); 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) if (config.Status == ConfigStatus.Revoked)
return Result.Success(); return Result.Success();
var inbound = await dbContext var revokeResult = await VpnConfigRevocation.RevokeAsync(
.Inbounds.AsNoTracking() dbContext,
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); gateway,
var node = inbound is null config,
? null logger,
: 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 cancellationToken
); );
if (!revokeResult.IsSuccess)
return revokeResult;
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( await notifier.NotifyConfigStatusChangedAsync(
userId, userId,
config.Id, 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.Type,
ticket.Status, ticket.Status,
null, null,
null,
null,
null,
null,
null,
ticket.CreatedAt, ticket.CreatedAt,
[commentDto] [commentDto]
); );
@@ -95,11 +95,6 @@ public sealed class CreateExtensionRequestTicketCommandHandler(
userName, userName,
ticket.Type, ticket.Type,
ticket.Status, ticket.Status,
null,
null,
null,
null,
null,
ticket.RequestedDays, ticket.RequestedDays,
ticket.CreatedAt, ticket.CreatedAt,
[commentDto] [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( public sealed class GetTicketQueryHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IIdentityService identityService, IIdentityService identityService,
IRoleService roleService,
ICurrentUser currentUser ICurrentUser currentUser
) : IQueryHandler<GetTicketQuery, Result<TicketDetailDto>> ) : IQueryHandler<GetTicketQuery, Result<TicketDetailDto>>
{ {
@@ -33,7 +32,6 @@ public sealed class GetTicketQueryHandler(
var dto = await TicketMapping.ToDetailDtoAsync( var dto = await TicketMapping.ToDetailDtoAsync(
dbContext, dbContext,
identityService, identityService,
roleService,
ticket, ticket,
cancellationToken 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", "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( public static readonly Error TicketClosed = Error.Conflict(
"Support.TicketClosed", "Support.TicketClosed",
@@ -38,34 +29,24 @@ public static class SupportErrors
"Переоткрыть можно только решённый тикет." "Переоткрыть можно только решённый тикет."
); );
/// <summary>Заявка на роль/продление решается только через approve/reject — там же выполняется /// <summary>Заявка на продление решается только через approve/reject — там же выполняется сама
/// сама выдача роли/дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего /// выдача дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего не выдав
/// не выдав пользователю.</summary> /// пользователю.</summary>
public static readonly Error OnlyBugReportCanBeResolvedDirectly = Error.Validation( public static readonly Error OnlyBugReportCanBeResolvedDirectly = Error.Validation(
"Support.OnlyBugReportCanBeResolvedDirectly", "Support.OnlyBugReportCanBeResolvedDirectly",
"Заявку на роль или продление можно только одобрить/отклонить, а не решить напрямую." "Заявку на продление можно только одобрить/отклонить, а не решить напрямую."
); );
public static readonly Error OnlyBugReportCanBeClosedDirectly = Error.Validation( public static readonly Error OnlyBugReportCanBeClosedDirectly = Error.Validation(
"Support.OnlyBugReportCanBeClosedDirectly", "Support.OnlyBugReportCanBeClosedDirectly",
"Заявку на роль или продление можно только одобрить/отклонить, а не закрыть напрямую." "Заявку на продление можно только одобрить/отклонить, а не закрыть напрямую."
); );
/// <summary>Заявка на роль/продление одноразовая: повторное одобрение начислило бы дни/роль ещё /// <summary>Заявка на продление одноразовая: повторное одобрение начислило бы дни ещё раз. Новый
/// раз. Новый запрос — новый тикет, не переоткрытие старого.</summary> /// запрос — новый тикет, не переоткрытие старого.</summary>
public static readonly Error OnlyBugReportCanBeReopened = Error.Validation( public static readonly Error OnlyBugReportCanBeReopened = Error.Validation(
"Support.OnlyBugReportCanBeReopened", "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( public static readonly Error ExtensionRequestAlreadyPending = Error.Conflict(
@@ -4,8 +4,8 @@ namespace PnvPanel.Application.Support;
internal static class TicketAuthorization internal static class TicketAuthorization
{ {
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в // Совпадает со значением Infrastructure.Identity.RoleNames.Admin (Application не может
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure). // ссылаться на Infrastructure).
private const string AdminRoleName = "admin"; private const string AdminRoleName = "admin";
public static bool IsAdmin(CurrentUserProfile? profile) => public static bool IsAdmin(CurrentUserProfile? profile) =>
@@ -3,8 +3,6 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support; namespace PnvPanel.Application.Support;
/// <summary> /// <summary>
/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером,
/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName.
/// RequestedDays — только для ExtensionRequest (продление оплаченного периода). /// RequestedDays — только для ExtensionRequest (продление оплаченного периода).
/// </summary> /// </summary>
public sealed record TicketDetailDto( public sealed record TicketDetailDto(
@@ -13,11 +11,6 @@ public sealed record TicketDetailDto(
string UserName, string UserName,
TicketType Type, TicketType Type,
TicketStatus Status, TicketStatus Status,
Guid? RequestedRoleId,
string? RequestedRoleName,
string? ProposedRoleName,
int? ProposedMaxConfigs,
int? ProposedMaxIpLimit,
int? RequestedDays, int? RequestedDays,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
IReadOnlyList<TicketCommentDto> Comments IReadOnlyList<TicketCommentDto> Comments
@@ -15,7 +15,6 @@ internal static class TicketMapping
public static async Task<TicketDetailDto> ToDetailDtoAsync( public static async Task<TicketDetailDto> ToDetailDtoAsync(
IAppDbContext dbContext, IAppDbContext dbContext,
IIdentityService identityService, IIdentityService identityService,
IRoleService roleService,
SupportTicket ticket, SupportTicket ticket,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
@@ -39,13 +38,6 @@ internal static class TicketMapping
var userIds = comments.Select(c => c.AuthorId).Append(ticket.UserId).Distinct().ToList(); var userIds = comments.Select(c => c.AuthorId).Append(ticket.UserId).Distinct().ToList();
var userNames = await identityService.GetUserNamesAsync(userIds, cancellationToken); 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 var commentDtos = comments
.Select(c => new TicketCommentDto( .Select(c => new TicketCommentDto(
c.Id, c.Id,
@@ -71,11 +63,6 @@ internal static class TicketMapping
userNames.GetValueOrDefault(ticket.UserId, "?"), userNames.GetValueOrDefault(ticket.UserId, "?"),
ticket.Type, ticket.Type,
ticket.Status, ticket.Status,
ticket.RequestedRoleId,
requestedRoleName,
ticket.ProposedRoleName,
ticket.ProposedMaxConfigs,
ticket.ProposedMaxIpLimit,
ticket.RequestedDays, ticket.RequestedDays,
ticket.CreatedAt, ticket.CreatedAt,
commentDtos commentDtos
@@ -5,14 +5,14 @@ namespace PnvPanel.Domain.Billing;
/// <summary> /// <summary>
/// Заявка пользователя на оплату. Kind.Subscription — оплата подписки за период (квартал/полгода/год, /// Заявка пользователя на оплату. Kind.Subscription — оплата подписки за период (квартал/полгода/год,
/// Period задан), продлевает BillingPaidUntil при подтверждении. Kind.RoleChangeTopUp — доплата разницы /// Period задан), продлевает BillingPaidUntil при подтверждении. Kind.PlanChangeTopUp — доплата разницы
/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан), подтверждение НЕ меняет /// в цене при увеличении тарифа с активным оплаченным периодом (Period не задан), подтверждение НЕ меняет
/// BillingPaidUntil — см. RoleChangeTopUp.Compute и ConfirmPaymentRequestCommandHandler. Сумма /// BillingPaidUntil — см. PlanChangeTopUp.Compute и ConfirmPaymentRequestCommandHandler. Сумма
/// замораживается на момент создания (по действовавшей на тот момент ставке PricingSettings) — /// замораживается на момент создания (по действовавшей на тот момент ставке PricingSettings) —
/// последующее изменение прайса админом не меняет уже созданные заявки. Не более одной активной /// последующее изменение прайса админом не меняет уже созданные заявки. Не более одной активной
/// (AwaitingPayment/AwaitingConfirmation) заявки Kind.Subscription на пользователя — инвариант /// (AwaitingPayment/AwaitingConfirmation) заявки Kind.Subscription на пользователя — инвариант
/// проверяется на уровне Application; RoleChangeTopUp создаётся системой при одобрении заявки на роль /// проверяется на уровне Application; PlanChangeTopUp создаётся системой при самостоятельной смене
/// и этим инвариантом не ограничен. /// тарифа и этим инвариантом не ограничен.
/// </summary> /// </summary>
public sealed class PaymentRequest : Entity 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 return new PaymentRequest
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
UserId = userId, UserId = userId,
Kind = PaymentRequestKind.RoleChangeTopUp, Kind = PaymentRequestKind.PlanChangeTopUp,
Period = null, Period = null,
AmountSnapshot = amountSnapshot, AmountSnapshot = amountSnapshot,
Status = PaymentRequestStatus.AwaitingPayment, Status = PaymentRequestStatus.AwaitingPayment,
@@ -1,10 +1,10 @@
namespace PnvPanel.Domain.Billing; namespace PnvPanel.Domain.Billing;
/// <summary>Subscription — обычная оплата за период (Period задан). RoleChangeTopUp — доплата разницы /// <summary>Subscription — обычная оплата за период (Period задан). PlanChangeTopUp — доплата разницы
/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан, не продлевает /// в цене при увеличении тарифа с активным оплаченным периодом (Period не задан, не продлевает
/// BillingPaidUntil) — см. RoleChangeTopUp.Compute.</summary> /// BillingPaidUntil) — см. PlanChangeTopUp.Compute.</summary>
public enum PaymentRequestKind public enum PaymentRequestKind
{ {
Subscription, 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; namespace PnvPanel.Domain.Support;
/// <summary> /// <summary>
/// Обращение в поддержку: баг/предложение (свободная форма) либо заявка на роль (существующая роль /// Обращение в поддержку: баг/предложение (свободная форма) либо заявка на продление оплаченного
/// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не /// периода. Текст обращения и переписка — в TicketComment, отдельной таблицей (не навигационная
/// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig). /// коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig). Для
/// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками, /// ExtensionRequest (продление оплаченного периода — только для billing-ролей, см.
/// а не runtime-проверкой одного универсального конструктора. Для ExtensionRequest (продление /// AppRole.BillingEnabled) заполнен RequestedDays.
/// оплаченного периода — только для billing-ролей, см. AppRole.BillingEnabled) заполнен RequestedDays.
/// </summary> /// </summary>
public sealed class SupportTicket : Entity public sealed class SupportTicket : Entity
{ {
public Guid UserId { get; private set; } public Guid UserId { get; private set; }
public TicketType Type { get; private set; } public TicketType Type { get; private set; }
public TicketStatus Status { 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 int? RequestedDays { get; private set; }
public DateTimeOffset CreatedAt { 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) public static SupportTicket CreateExtensionRequest(Guid userId, int requestedDays)
{ {
return new SupportTicket return new SupportTicket
@@ -83,7 +45,7 @@ public sealed class SupportTicket : Entity
}; };
} }
/// <summary>Решено (в т.ч. заявка на роль одобрена — роль выдаётся оркестрацией на уровне Application).</summary> /// <summary>Решено (в т.ч. заявка на продление одобрена — дни начисляются оркестрацией на уровне Application).</summary>
public void Resolve() public void Resolve()
{ {
if (Status != TicketStatus.Open) if (Status != TicketStatus.Open)
@@ -92,7 +54,7 @@ public sealed class SupportTicket : Entity
Status = TicketStatus.Resolved; Status = TicketStatus.Resolved;
} }
/// <summary>Финальное состояние — обратного пути нет (в т.ч. заявка на роль отклонена).</summary> /// <summary>Финальное состояние — обратного пути нет (в т.ч. заявка на продление отклонена).</summary>
public void Close() public void Close()
{ {
if (Status == TicketStatus.Closed) if (Status == TicketStatus.Closed)
@@ -3,6 +3,5 @@ namespace PnvPanel.Domain.Support;
public enum TicketType public enum TicketType
{ {
BugReport, BugReport,
RoleRequest,
ExtensionRequest, ExtensionRequest,
} }
@@ -60,8 +60,8 @@ public sealed class BillingService(
// отклонит, но и не просто "ничего не делаем": держим клиента рабочим на панели (Xray // отклонит, но и не просто "ничего не делаем": держим клиента рабочим на панели (Xray
// проверяет expiryTime сам, независимо от нашего статуса) — см. // проверяет expiryTime сам, независимо от нашего статуса) — см.
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler и // ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler и
// BillingConfigResumer.ProtectPendingConfigsAsync. RoleChangeTopUp намеренно не учитывается // BillingConfigResumer.ProtectPendingConfigsAsync. PlanChangeTopUp намеренно не учитывается
// здесь — это доплата за апгрейд роли, а не оплата подписки, её ожидание не должно // здесь — это доплата за увеличение тарифа, а не оплата подписки, её ожидание не должно
// спасать от приостановки за реально просроченную подписку. // спасать от приостановки за реально просроченную подписку.
var hasAwaitingConfirmation = await dbContext.PaymentRequests.AnyAsync( var hasAwaitingConfirmation = await dbContext.PaymentRequests.AnyAsync(
r => r =>
@@ -7,11 +7,13 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Plans;
using PnvPanel.Infrastructure.Apps; using PnvPanel.Infrastructure.Apps;
using PnvPanel.Infrastructure.BackgroundJobs; using PnvPanel.Infrastructure.BackgroundJobs;
using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Identity;
using PnvPanel.Infrastructure.Instructions; using PnvPanel.Infrastructure.Instructions;
using PnvPanel.Infrastructure.Persistence; using PnvPanel.Infrastructure.Persistence;
using PnvPanel.Infrastructure.Plans;
using PnvPanel.Infrastructure.Pricing; using PnvPanel.Infrastructure.Pricing;
using PnvPanel.Infrastructure.Security; using PnvPanel.Infrastructure.Security;
using PnvPanel.Infrastructure.Storage; using PnvPanel.Infrastructure.Storage;
@@ -68,6 +70,7 @@ public static class DependencyInjection
configuration.GetSection(AdminSeedOptions.SectionName) configuration.GetSection(AdminSeedOptions.SectionName)
); );
services.Configure<RolesOptions>(configuration.GetSection(RolesOptions.SectionName)); services.Configure<RolesOptions>(configuration.GetSection(RolesOptions.SectionName));
services.Configure<PlansOptions>(configuration.GetSection(PlansOptions.SectionName));
var jwtOptions = var jwtOptions =
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
@@ -141,6 +144,7 @@ public static class DependencyInjection
services.AddScoped<IClientAppCatalogSeeder, ClientAppCatalogSeeder>(); services.AddScoped<IClientAppCatalogSeeder, ClientAppCatalogSeeder>();
services.AddScoped<IInstructionIntroSeeder, InstructionIntroSeeder>(); services.AddScoped<IInstructionIntroSeeder, InstructionIntroSeeder>();
services.AddScoped<IPricingSettingsSeeder, PricingSettingsSeeder>(); services.AddScoped<IPricingSettingsSeeder, PricingSettingsSeeder>();
services.AddScoped<IPlanSeeder, PlanSeeder>();
// Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как // Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как
// ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService). // ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService).
services.AddScoped<CurrentUser>(); services.AddScoped<CurrentUser>();
@@ -3,17 +3,15 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Infrastructure.Identity; namespace PnvPanel.Infrastructure.Identity;
/// <summary>Роль с квотой на число конфигов и лимитом одновременных IP на клиента. У пользователя ровно одна роль.</summary> /// <summary>Роль с лимитом одновременных IP на клиента, доступом к инбаундам и флагом биллинга.
/// Квота конфигов больше не на роли — см. AppUser.ConfigQuota/Plan. У пользователя ровно одна роль.</summary>
public class AppRole : IdentityRole<Guid> public class AppRole : IdentityRole<Guid>
{ {
public const int UnlimitedMaxConfigs = RoleQuota.Unlimited;
public const int UnlimitedMaxIpLimit = RoleQuota.Unlimited; public const int UnlimitedMaxIpLimit = RoleQuota.Unlimited;
public int MaxConfigs { get; set; }
/// <summary>Лимит одновременных IP на клиента в 3x-ui (`limitIp`); -1 — без лимита. Применяется /// <summary>Лимит одновременных IP на клиента в 3x-ui (`limitIp`); -1 — без лимита. Применяется
/// только к новым клиентам, создаваемым в 3x-ui (см. IXuiPanelGateway.AddClientAsync) — при смене /// только к новым клиентам, создаваемым в 3x-ui (см. IXuiPanelGateway.AddClientAsync) — при смене
/// роли/лимита существующие клиенты в панели не трогаются (как и квота MaxConfigs).</summary> /// роли/лимита существующие клиенты в панели не трогаются (как и квота ConfigQuota).</summary>
public int MaxIpLimit { get; set; } public int MaxIpLimit { get; set; }
public bool IsSystem { get; set; } public bool IsSystem { get; set; }
@@ -15,6 +15,15 @@ public class AppUser : IdentityUser<Guid>
/// <summary>Блокировка админом: вход запрещён, все конфиги отключаются в 3x-ui (см. BlockUserCommandHandler).</summary> /// <summary>Блокировка админом: вход запрещён, все конфиги отключаются в 3x-ui (см. BlockUserCommandHandler).</summary>
public bool IsBlocked { get; set; } 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> /// <summary>Секрет для агрегированной подписки /sub/{token} (все активные конфиги пользователя).</summary>
public string SubscriptionToken { get; set; } = string.Empty; public string SubscriptionToken { get; set; } = string.Empty;
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Infrastructure.Identity; namespace PnvPanel.Infrastructure.Identity;
@@ -13,6 +14,7 @@ public sealed class DbInitializer(
IClientAppCatalogSeeder clientAppCatalogSeeder, IClientAppCatalogSeeder clientAppCatalogSeeder,
IInstructionIntroSeeder instructionIntroSeeder, IInstructionIntroSeeder instructionIntroSeeder,
IPricingSettingsSeeder pricingSettingsSeeder, IPricingSettingsSeeder pricingSettingsSeeder,
IPlanSeeder planSeeder,
IOptions<AdminSeedOptions> adminSeedOptions, IOptions<AdminSeedOptions> adminSeedOptions,
IOptions<RolesOptions> rolesOptions, IOptions<RolesOptions> rolesOptions,
ILogger<DbInitializer> logger ILogger<DbInitializer> logger
@@ -20,15 +22,9 @@ public sealed class DbInitializer(
{ {
public async Task SeedAsync(CancellationToken cancellationToken = default) public async Task SeedAsync(CancellationToken cancellationToken = default)
{ {
await EnsureRoleAsync( await EnsureRoleAsync(RoleNames.Admin, AppRole.UnlimitedMaxIpLimit, isSystem: true);
RoleNames.Admin,
AppRole.UnlimitedMaxConfigs,
AppRole.UnlimitedMaxIpLimit,
isSystem: true
);
await EnsureRoleAsync( await EnsureRoleAsync(
RoleNames.User, RoleNames.User,
rolesOptions.Value.DefaultUserMaxConfigs,
rolesOptions.Value.DefaultUserMaxIpLimit, rolesOptions.Value.DefaultUserMaxIpLimit,
isSystem: true isSystem: true
); );
@@ -36,19 +32,15 @@ public sealed class DbInitializer(
await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken); await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken);
await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken); await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken);
await pricingSettingsSeeder.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)) if (await roleManager.RoleExistsAsync(name))
return; return;
var role = new AppRole(name) var role = new AppRole(name) { MaxIpLimit = maxIpLimit, IsSystem = isSystem };
{
MaxConfigs = maxConfigs,
MaxIpLimit = maxIpLimit,
IsSystem = isSystem,
};
var result = await roleManager.CreateAsync(role); var result = await roleManager.CreateAsync(role);
if (!result.Succeeded) if (!result.Succeeded)
{ {
@@ -82,6 +74,7 @@ public sealed class DbInitializer(
UserName = options.Username, UserName = options.Username,
IsActivated = true, IsActivated = true,
ActivatedAt = DateTimeOffset.UtcNow, ActivatedAt = DateTimeOffset.UtcNow,
ConfigQuota = RoleQuota.Unlimited,
SubscriptionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)), SubscriptionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)),
}; };
@@ -1,6 +1,7 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -12,7 +13,8 @@ internal sealed class IdentityService(
UserManager<AppUser> userManager, UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager, SignInManager<AppUser> signInManager,
RoleManager<AppRole> roleManager, RoleManager<AppRole> roleManager,
AppDbContext dbContext AppDbContext dbContext,
IOptions<RolesOptions> rolesOptions
) : IIdentityService ) : IIdentityService
{ {
public async Task<Result<Guid>> CreateUserAsync( public async Task<Result<Guid>> CreateUserAsync(
@@ -25,6 +27,7 @@ internal sealed class IdentityService(
{ {
UserName = userName, UserName = userName,
IsActivated = false, IsActivated = false,
ConfigQuota = rolesOptions.Value.DefaultUserMaxConfigs,
SubscriptionToken = GenerateSubscriptionToken(), SubscriptionToken = GenerateSubscriptionToken(),
}; };
var createResult = await userManager.CreateAsync(user, password); var createResult = await userManager.CreateAsync(user, password);
@@ -91,7 +94,8 @@ internal sealed class IdentityService(
role.Name!, role.Name!,
user.IsActivated, user.IsActivated,
user.IsBlocked, user.IsBlocked,
role.MaxConfigs, user.ConfigQuota,
user.PlanId,
role.MaxIpLimit, role.MaxIpLimit,
user.SubscriptionToken, user.SubscriptionToken,
role.BillingEnabled, role.BillingEnabled,
@@ -492,6 +496,23 @@ internal sealed class IdentityService(
return Result.Success(); 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) private async Task<string> GetPrimaryRoleNameAsync(AppUser user)
{ {
var roles = await userManager.GetRolesAsync(user); var roles = await userManager.GetRolesAsync(user);
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PnvPanel.Application.Admin.Roles; using PnvPanel.Application.Admin.Roles;
using PnvPanel.Application.Admin.Users; using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
@@ -11,12 +12,12 @@ namespace PnvPanel.Infrastructure.Identity;
internal sealed class RoleService( internal sealed class RoleService(
RoleManager<AppRole> roleManager, RoleManager<AppRole> roleManager,
UserManager<AppUser> userManager, UserManager<AppUser> userManager,
IAppDbContext dbContext IAppDbContext dbContext,
IOptions<RolesOptions> rolesOptions
) : IRoleService ) : IRoleService
{ {
public async Task<Result<RoleDto>> CreateRoleAsync( public async Task<Result<RoleDto>> CreateRoleAsync(
string name, string name,
int maxConfigs,
int maxIpLimit, int maxIpLimit,
bool billingEnabled, bool billingEnabled,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -30,7 +31,6 @@ internal sealed class RoleService(
var role = new AppRole(name) var role = new AppRole(name)
{ {
MaxConfigs = maxConfigs,
MaxIpLimit = maxIpLimit, MaxIpLimit = maxIpLimit,
IsSystem = false, IsSystem = false,
BillingEnabled = billingEnabled, BillingEnabled = billingEnabled,
@@ -51,7 +51,6 @@ internal sealed class RoleService(
public async Task<Result<RoleDto>> UpdateRoleAsync( public async Task<Result<RoleDto>> UpdateRoleAsync(
Guid roleId, Guid roleId,
int maxConfigs,
int maxIpLimit, int maxIpLimit,
bool billingEnabled, bool billingEnabled,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -69,7 +68,6 @@ internal sealed class RoleService(
var billingJustEnabled = billingEnabled && !role.BillingEnabled; var billingJustEnabled = billingEnabled && !role.BillingEnabled;
role.MaxConfigs = maxConfigs;
role.MaxIpLimit = maxIpLimit; role.MaxIpLimit = maxIpLimit;
role.BillingEnabled = billingEnabled; role.BillingEnabled = billingEnabled;
await roleManager.UpdateAsync(role); await roleManager.UpdateAsync(role);
@@ -137,14 +135,7 @@ internal sealed class RoleService(
{ {
return await roleManager return await roleManager
.Roles.OrderBy(r => r.Name) .Roles.OrderBy(r => r.Name)
.Select(r => new RoleDto( .Select(r => new RoleDto(r.Id, r.Name!, r.MaxIpLimit, r.IsSystem, r.BillingEnabled))
r.Id,
r.Name!,
r.MaxConfigs,
r.MaxIpLimit,
r.IsSystem,
r.BillingEnabled
))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
@@ -178,6 +169,22 @@ internal sealed class RoleService(
await userManager.AddToRoleAsync(user, role.Name!); 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) if (role.BillingEnabled)
await InitializeBillingGraceAsync([user], cancellationToken); await InitializeBillingGraceAsync([user], cancellationToken);
@@ -185,5 +192,5 @@ internal sealed class RoleService(
} }
private static RoleDto ToDto(AppRole role) => 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.Instructions;
using PnvPanel.Domain.News; using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Plans;
using PnvPanel.Domain.Pricing; using PnvPanel.Domain.Pricing;
using PnvPanel.Domain.Support; using PnvPanel.Domain.Support;
using PnvPanel.Domain.Telegram; using PnvPanel.Domain.Telegram;
@@ -65,6 +66,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<PaymentRequest> PaymentRequests => Set<PaymentRequest>(); public DbSet<PaymentRequest> PaymentRequests => Set<PaymentRequest>();
public DbSet<Plan> Plans => Set<Plan>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(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.Type).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.Status).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.UserId, x.Status });
builder.HasIndex(x => new { x.Type, 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); 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 => modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -645,22 +670,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .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") b.Property<int?>("RequestedDays")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<Guid?>("RequestedRoleId")
.HasColumnType("uuid");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
@@ -823,9 +835,6 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<bool>("IsSystem") b.Property<bool>("IsSystem")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<int>("MaxConfigs")
.HasColumnType("integer");
b.Property<int>("MaxIpLimit") b.Property<int>("MaxIpLimit")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -874,6 +883,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
.IsConcurrencyToken() .IsConcurrencyToken()
.HasColumnType("text"); .HasColumnType("text");
b.Property<int>("ConfigQuota")
.HasColumnType("integer");
b.Property<string>("Email") b.Property<string>("Email")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
@@ -910,6 +922,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<bool>("PhoneNumberConfirmed") b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<Guid?>("PlanId")
.HasColumnType("uuid");
b.Property<string>("SecurityStamp") b.Property<string>("SecurityStamp")
.HasColumnType("text"); .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", "premium",
IsActivated: true, IsActivated: true,
IsBlocked: false, IsBlocked: false,
MaxConfigs: 5, ConfigQuota: 5,
PlanId: null,
MaxIpLimit: 3, MaxIpLimit: 3,
SubscriptionToken: "sub-token", SubscriptionToken: "sub-token",
BillingEnabled: true, BillingEnabled: true,
@@ -225,13 +226,13 @@ public class ConfirmPaymentRequestCommandHandlerTests
} }
[Fact] [Fact]
public async Task Handle_WhenRoleChangeTopUp_ConfirmsWithoutExtendingPaidUntil() public async Task Handle_WhenPlanChangeTopUp_ConfirmsWithoutExtendingPaidUntil()
{ {
using var dbContext = InMemoryDbContextFactory.Create(); using var dbContext = InMemoryDbContextFactory.Create();
var adminId = Guid.NewGuid(); var adminId = Guid.NewGuid();
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var existingPaidUntil = DateTimeOffset.UtcNow.AddDays(20); var existingPaidUntil = DateTimeOffset.UtcNow.AddDays(20);
var request = PaymentRequest.CreateRoleChangeTopUp(userId, 1200); var request = PaymentRequest.CreatePlanChangeTopUp(userId, 1200);
request.MarkPaymentSent(); request.MarkPaymentSent();
dbContext.PaymentRequests.Add(request); dbContext.PaymentRequests.Add(request);
await dbContext.SaveChangesAsync(CancellationToken.None); await dbContext.SaveChangesAsync(CancellationToken.None);
@@ -28,7 +28,8 @@ public class GrantBillingGiftCommandHandlerTests
"premium", "premium",
IsActivated: true, IsActivated: true,
IsBlocked: false, IsBlocked: false,
MaxConfigs: 5, ConfigQuota: 5,
PlanId: null,
MaxIpLimit: 3, MaxIpLimit: 3,
SubscriptionToken: "sub-token", SubscriptionToken: "sub-token",
BillingEnabled: billingEnabled, BillingEnabled: billingEnabled,
@@ -17,7 +17,7 @@ public class ListPaymentRequestsQueryHandlerTests
using var dbContext = InMemoryDbContextFactory.Create(); using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var subscription = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500); 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); dbContext.PaymentRequests.AddRange(subscription, topUp);
await dbContext.SaveChangesAsync(CancellationToken.None); await dbContext.SaveChangesAsync(CancellationToken.None);
@@ -30,7 +30,7 @@ public class ListPaymentRequestsQueryHandlerTests
var result = await handler.Handle( var result = await handler.Handle(
new ListPaymentRequestsQuery( new ListPaymentRequestsQuery(
StatusFilter: null, StatusFilter: null,
KindFilter: PaymentRequestKind.RoleChangeTopUp, KindFilter: PaymentRequestKind.PlanChangeTopUp,
Search: null, Search: null,
Page: 1, Page: 1,
PageSize: 20 PageSize: 20
@@ -42,7 +42,8 @@ public class RejectPaymentRequestCommandHandlerTests
"premium", "premium",
IsActivated: true, IsActivated: true,
IsBlocked: false, IsBlocked: false,
MaxConfigs: 5, ConfigQuota: 5,
PlanId: null,
MaxIpLimit: 3, MaxIpLimit: 3,
SubscriptionToken: "sub-token", SubscriptionToken: "sub-token",
BillingEnabled: true, BillingEnabled: true,
@@ -72,8 +72,8 @@ public class FactoryResetCommandHandlerTests
.Returns( .Returns(
new List<RoleDto> new List<RoleDto>
{ {
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false), new(adminRoleId, "admin", -1, IsSystem: true, BillingEnabled: false),
new(customRoleId, "premium", 10, 5, IsSystem: false, BillingEnabled: false), new(customRoleId, "premium", 5, IsSystem: false, BillingEnabled: false),
} }
); );
_roleService _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", "premium",
IsActivated: true, IsActivated: true,
IsBlocked: false, IsBlocked: false,
MaxConfigs: 5, ConfigQuota: 5,
PlanId: null,
MaxIpLimit: 3, MaxIpLimit: 3,
SubscriptionToken: "sub-token", SubscriptionToken: "sub-token",
BillingEnabled: true, 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