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