Implement billing status notification and enhance user management integration
- Added `NotifyBillingStatusChangedAsync` method to `IRealtimeNotifier` for notifying clients about changes in billing status. - Updated `BillingConfigResumer` to call the new notification method after modifying billing configurations, ensuring users receive real-time updates. - Enhanced `ListUsersQueryHandler` to include a `BillingPendingReview` property in `UserSummaryDto`, indicating if a user has a pending payment request awaiting confirmation. - Refactored various command handlers to utilize `AdvisoryLock` for managing concurrent requests, preventing race conditions in billing operations. - Updated tests to cover new notification behaviors and ensure proper functionality in billing status management.
This commit is contained in:
+29
-13
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Concurrency;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
@@ -8,6 +9,8 @@ using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
|
||||
/// <summary>Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см.
|
||||
/// CreatePaymentRequestCommandHandler для полного обоснования.</summary>
|
||||
public sealed class CreateExtensionRequestTicketCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
@@ -31,22 +34,35 @@ public sealed class CreateExtensionRequestTicketCommandHandler(
|
||||
if (!profile.BillingEnabled)
|
||||
return Result.Failure<TicketDetailDto>(BillingErrors.NotEnabled);
|
||||
|
||||
var hasPending = await dbContext.SupportTickets.AnyAsync(
|
||||
t =>
|
||||
t.UserId == userId
|
||||
&& t.Type == TicketType.ExtensionRequest
|
||||
&& t.Status == TicketStatus.Open,
|
||||
var claimed = await AdvisoryLock.RunAsync(
|
||||
dbContext,
|
||||
userId,
|
||||
async lockedCancellationToken =>
|
||||
{
|
||||
var hasPending = await dbContext.SupportTickets.AnyAsync(
|
||||
t =>
|
||||
t.UserId == userId
|
||||
&& t.Type == TicketType.ExtensionRequest
|
||||
&& t.Status == TicketStatus.Open,
|
||||
lockedCancellationToken
|
||||
);
|
||||
if (hasPending)
|
||||
return Result.Failure<(SupportTicket, TicketComment)>(
|
||||
SupportErrors.ExtensionRequestAlreadyPending
|
||||
);
|
||||
|
||||
var newTicket = SupportTicket.CreateExtensionRequest(userId, command.RequestedDays);
|
||||
dbContext.SupportTickets.Add(newTicket);
|
||||
var newComment = TicketComment.Create(newTicket.Id, userId, command.Justification);
|
||||
dbContext.TicketComments.Add(newComment);
|
||||
return Result.Success((newTicket, newComment));
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
if (hasPending)
|
||||
return Result.Failure<TicketDetailDto>(SupportErrors.ExtensionRequestAlreadyPending);
|
||||
|
||||
var ticket = SupportTicket.CreateExtensionRequest(userId, command.RequestedDays);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
|
||||
var comment = TicketComment.Create(ticket.Id, userId, command.Justification);
|
||||
dbContext.TicketComments.Add(comment);
|
||||
if (!claimed.IsSuccess)
|
||||
return Result.Failure<TicketDetailDto>(claimed.Error);
|
||||
|
||||
var (ticket, comment) = claimed.Value;
|
||||
var userName = currentUser.UserName ?? userId.ToString();
|
||||
|
||||
await notifier.NotifyTicketCreatedAsync(
|
||||
|
||||
+40
-27
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -7,6 +8,8 @@ using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateRoleRequest;
|
||||
|
||||
/// <summary>Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см.
|
||||
/// CreatePaymentRequestCommandHandler для полного обоснования.</summary>
|
||||
public sealed class CreateRoleRequestTicketCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IRoleService roleService,
|
||||
@@ -27,47 +30,57 @@ public sealed class CreateRoleRequestTicketCommandHandler(
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var hasPending = await dbContext.SupportTickets.AnyAsync(
|
||||
t =>
|
||||
t.UserId == userId
|
||||
&& t.Type == TicketType.RoleRequest
|
||||
&& t.Status == TicketStatus.Open,
|
||||
cancellationToken
|
||||
);
|
||||
if (hasPending)
|
||||
return Result.Failure<TicketDetailDto>(SupportErrors.RoleRequestAlreadyPending);
|
||||
|
||||
SupportTicket ticket;
|
||||
string? requestedRoleName = null;
|
||||
|
||||
if (command.ExistingRoleId is { } roleId)
|
||||
if (command.ExistingRoleId is { } existingRoleId)
|
||||
{
|
||||
var roles = await roleService.ListRolesAsync(cancellationToken);
|
||||
var role = roles.FirstOrDefault(r => r.Id == roleId);
|
||||
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);
|
||||
|
||||
ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
|
||||
requestedRoleName = role.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
ticket = SupportTicket.CreateRoleRequestForNewRole(
|
||||
userId,
|
||||
command.NewRoleName!,
|
||||
command.NewRoleMaxConfigs!.Value,
|
||||
command.NewRoleMaxIpLimit!.Value
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
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 comment = TicketComment.Create(ticket.Id, userId, command.Justification);
|
||||
dbContext.TicketComments.Add(comment);
|
||||
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
|
||||
|
||||
@@ -28,6 +28,9 @@ public sealed class ReopenTicketCommandHandler(
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
if (ticket.Type != TicketType.BugReport)
|
||||
return Result.Failure(SupportErrors.OnlyBugReportCanBeReopened);
|
||||
|
||||
if (ticket.Status != TicketStatus.Resolved)
|
||||
return Result.Failure(SupportErrors.NotResolved);
|
||||
|
||||
|
||||
@@ -38,6 +38,26 @@ public static class SupportErrors
|
||||
"Переоткрыть можно только решённый тикет."
|
||||
);
|
||||
|
||||
/// <summary>Заявка на роль/продление решается только через approve/reject — там же выполняется
|
||||
/// сама выдача роли/дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего
|
||||
/// не выдав пользователю.</summary>
|
||||
public static readonly Error OnlyBugReportCanBeResolvedDirectly = Error.Validation(
|
||||
"Support.OnlyBugReportCanBeResolvedDirectly",
|
||||
"Заявку на роль или продление можно только одобрить/отклонить, а не решить напрямую."
|
||||
);
|
||||
|
||||
public static readonly Error OnlyBugReportCanBeClosedDirectly = Error.Validation(
|
||||
"Support.OnlyBugReportCanBeClosedDirectly",
|
||||
"Заявку на роль или продление можно только одобрить/отклонить, а не закрыть напрямую."
|
||||
);
|
||||
|
||||
/// <summary>Заявка на роль/продление одноразовая: повторное одобрение начислило бы дни/роль ещё
|
||||
/// раз. Новый запрос — новый тикет, не переоткрытие старого.</summary>
|
||||
public static readonly Error OnlyBugReportCanBeReopened = Error.Validation(
|
||||
"Support.OnlyBugReportCanBeReopened",
|
||||
"Заявку на роль или продление нельзя переоткрыть — оформите новую."
|
||||
);
|
||||
|
||||
public static readonly Error RoleRequestAlreadyPending = Error.Conflict(
|
||||
"Support.RoleRequestAlreadyPending",
|
||||
"У вас уже есть необработанная заявка на роль."
|
||||
|
||||
Reference in New Issue
Block a user