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