diff --git a/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs b/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs index a15d0da..e392707 100644 --- a/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs +++ b/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs @@ -151,4 +151,11 @@ internal sealed class SignalRRealtimeNotifier(IHubContext hubContext) .Clients.Group(GroupNames.User(userId)) .SendAsync("ticketUpdated", new { ticketId }, cancellationToken); } + + public Task NotifyBillingStatusChangedAsync(Guid userId, CancellationToken cancellationToken) + { + return hubContext + .Clients.Group(GroupNames.User(userId)) + .SendAsync("billingStatusChanged", new { userId }, cancellationToken); + } } diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs index cced610..6f54f0b 100644 --- a/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; 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; @@ -14,6 +15,9 @@ namespace PnvPanel.Application.Admin.Billing; /// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги, /// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя — /// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка). +/// Проверка статуса + продление PaidUntil + Confirm() — под AdvisoryLock (по Id заявки): без неё +/// конфирм с сайта, гонящийся с конфирмом из Telegram по одной и той же заявке, могли бы оба пройти +/// проверку "ещё не решена" и оба продлить PaidUntil — двойное начисление за одну оплату. /// public sealed class ConfirmPaymentRequestCommandHandler( IAppDbContext dbContext, @@ -33,75 +37,32 @@ public sealed class ConfirmPaymentRequestCommandHandler( if (currentUser.UserId is not { } adminId) return Result.Failure(AuthErrors.Unauthorized); - var request = await dbContext.PaymentRequests.FirstOrDefaultAsync( - r => r.Id == command.RequestId, + var claimed = await AdvisoryLock.RunAsync( + dbContext, + command.RequestId, + lockedCancellationToken => ClaimAndConfirmAsync(command.RequestId, adminId, lockedCancellationToken), cancellationToken ); - if (request is null) - return Result.Failure(BillingErrors.RequestNotFound); + if (!claimed.IsSuccess) + return Result.Failure(claimed.Error); - if ( - request.Status - is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation) - ) - return Result.Failure(BillingErrors.RequestNotDecidable); - - var profile = await identityService.GetProfileAsync(request.UserId, cancellationToken); - if (profile is null) - return Result.Failure(AuthErrors.Unauthorized); + var (request, newPaidUntil) = claimed.Value; // RoleChangeTopUp — доплата разницы в цене при апгрейде роли, не покупка времени: подтверждение - // не трогает BillingPaidUntil и не возвращает Expired-конфиги (это делает обычная Subscription- - // оплата/продление). Period не задан для этого Kind — ToMonths() здесь неприменим. - if (request.Kind == PaymentRequestKind.RoleChangeTopUp) + // не возвращает Expired-конфиги (это делает обычная Subscription-оплата/продление). + if (request.Kind == PaymentRequestKind.Subscription) { - request.Confirm(adminId); - - dbContext.AuditLogs.Add( - AuditLog.Create( - adminId, - "PaymentConfirmed", - "PaymentRequest", - request.Id.ToString(), - metadata: null, - AuditSource.Web - ) - ); - - await telegramNotifier.NotifyUserAsync( + await BillingConfigResumer.ResumeConfigsAsync( + dbContext, + gateway, + notifier, + logger, request.UserId, - "✅ Доплата за смену роли подтверждена.", - null, + newPaidUntil!.Value, cancellationToken ); - - return Result.Success(); } - var now = DateTimeOffset.UtcNow; - var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now; - var newPaidUntil = baseline.AddMonths(request.Period!.Value.ToMonths()); - - var extendResult = await identityService.ExtendBillingPaidUntilAsync( - request.UserId, - newPaidUntil, - cancellationToken - ); - if (!extendResult.IsSuccess) - return extendResult; - - request.Confirm(adminId); - - await BillingConfigResumer.ResumeConfigsAsync( - dbContext, - gateway, - notifier, - logger, - request.UserId, - newPaidUntil, - cancellationToken - ); - dbContext.AuditLogs.Add( AuditLog.Create( adminId, @@ -113,13 +74,60 @@ public sealed class ConfirmPaymentRequestCommandHandler( ) ); - await telegramNotifier.NotifyUserAsync( - request.UserId, - $"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.", - null, - cancellationToken - ); + var message = + request.Kind == PaymentRequestKind.RoleChangeTopUp + ? "✅ Доплата за смену роли подтверждена." + : $"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}."; + await telegramNotifier.NotifyUserAsync(request.UserId, message, null, cancellationToken); return Result.Success(); } + + /// Критическая секция под локом: проверка статуса, продление PaidUntil (для Subscription) + /// и сам Confirm() — всё атомарно вместе, чтобы гонка не могла продлить PaidUntil дважды. Внешний + /// I/O (гейтвей, Telegram) сюда намеренно не входит — см. AdvisoryLock. + private async Task> ClaimAndConfirmAsync( + Guid requestId, + Guid adminId, + CancellationToken cancellationToken + ) + { + var request = await dbContext.PaymentRequests.FirstOrDefaultAsync( + r => r.Id == requestId, + cancellationToken + ); + if (request is null) + return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotFound); + + if ( + request.Status + is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation) + ) + return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotDecidable); + + if (request.Kind == PaymentRequestKind.RoleChangeTopUp) + { + request.Confirm(adminId); + return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, null)); + } + + var profile = await identityService.GetProfileAsync(request.UserId, cancellationToken); + if (profile is null) + return Result.Failure<(PaymentRequest, DateTimeOffset?)>(AuthErrors.Unauthorized); + + var now = DateTimeOffset.UtcNow; + var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now; + var newPaidUntil = baseline.AddMonths(request.Period!.Value.ToMonths()); + + var extendResult = await identityService.ExtendBillingPaidUntilAsync( + request.UserId, + newPaidUntil, + cancellationToken + ); + if (!extendResult.IsSuccess) + return Result.Failure<(PaymentRequest, DateTimeOffset?)>(extendResult.Error); + + request.Confirm(adminId); + return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, newPaidUntil)); + } } diff --git a/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs index d5d8a1f..ae4ab58 100644 --- a/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; 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; @@ -10,6 +11,9 @@ using PnvPanel.Domain.Billing; namespace PnvPanel.Application.Admin.Billing; +/// Проверка статуса + Reject() — под AdvisoryLock (по Id заявки), тот же приём, что и в +/// ConfirmPaymentRequestCommandHandler — без него отклонение с сайта, гонящееся с отклонением из +/// Telegram, могли бы оба пройти проверку "ещё не решена". public sealed class RejectPaymentRequestCommandHandler( IAppDbContext dbContext, IIdentityService identityService, @@ -28,20 +32,33 @@ public sealed class RejectPaymentRequestCommandHandler( if (currentUser.UserId is not { } adminId) return Result.Failure(AuthErrors.Unauthorized); - var request = await dbContext.PaymentRequests.FirstOrDefaultAsync( - r => r.Id == command.RequestId, + var claimed = await AdvisoryLock.RunAsync( + dbContext, + command.RequestId, + async lockedCancellationToken => + { + var fresh = await dbContext.PaymentRequests.FirstOrDefaultAsync( + r => r.Id == command.RequestId, + lockedCancellationToken + ); + if (fresh is null) + return Result.Failure(BillingErrors.RequestNotFound); + + if ( + fresh.Status + is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation) + ) + return Result.Failure(BillingErrors.RequestNotDecidable); + + fresh.Reject(adminId, command.Reason); + return Result.Success(fresh); + }, cancellationToken ); - if (request is null) - return Result.Failure(BillingErrors.RequestNotFound); + if (!claimed.IsSuccess) + return Result.Failure(claimed.Error); - if ( - request.Status - is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation) - ) - return Result.Failure(BillingErrors.RequestNotDecidable); - - request.Reject(adminId, command.Reason); + var request = claimed.Value; // Пока заявка висела на проверке, конфиги могли быть временно "защищены" на панели // (ProtectPendingConfigsAsync — enable/expiresAt подвинуты вперёд без изменения локального diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveExtensionRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveExtensionRequestCommandHandler.cs index 60fa033..5e3d051 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/ApproveExtensionRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveExtensionRequestCommandHandler.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; 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; @@ -11,6 +12,9 @@ using PnvPanel.Domain.Support; namespace PnvPanel.Application.Admin.Support; +/// Проверка статуса + продление PaidUntil + Resolve() — под AdvisoryLock (по Id тикета): без +/// неё одобрение с сайта, гонящееся с одобрением из Telegram по одной и той же заявке, могли бы оба +/// пройти проверку "ещё не решена" и оба продлить PaidUntil — двойное начисление дней. public sealed class ApproveExtensionRequestCommandHandler( IAppDbContext dbContext, IIdentityService identityService, @@ -29,22 +33,60 @@ public sealed class ApproveExtensionRequestCommandHandler( 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, newPaidUntil) = claimed.Value; + + await BillingConfigResumer.ResumeConfigsAsync( + dbContext, + gateway, + notifier, + logger, + ticket.UserId, + newPaidUntil, + cancellationToken + ); + + await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); + await telegramNotifier.NotifyUserAsync( + ticket.UserId, + $"✅ Заявка на продление одобрена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.", + $"/support?ticket={ticket.Id}", + cancellationToken + ); + + return Result.Success(); + } + + private async Task> ApproveAsync( + Guid ticketId, + Guid adminId, + CancellationToken cancellationToken + ) + { var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( - t => t.Id == command.TicketId, + t => t.Id == ticketId, cancellationToken ); if (ticket is null) - return Result.Failure(SupportErrors.NotFound); + return Result.Failure<(SupportTicket, DateTimeOffset)>(SupportErrors.NotFound); if (ticket.Type != TicketType.ExtensionRequest) - return Result.Failure(SupportErrors.NotExtensionRequest); + return Result.Failure<(SupportTicket, DateTimeOffset)>(SupportErrors.NotExtensionRequest); if (ticket.Status != TicketStatus.Open) - return Result.Failure(SupportErrors.NotOpen); + return Result.Failure<(SupportTicket, DateTimeOffset)>(SupportErrors.NotOpen); var profile = await identityService.GetProfileAsync(ticket.UserId, cancellationToken); if (profile is null) - return Result.Failure(AuthErrors.Unauthorized); + return Result.Failure<(SupportTicket, DateTimeOffset)>(AuthErrors.Unauthorized); var now = DateTimeOffset.UtcNow; var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now; @@ -56,17 +98,7 @@ public sealed class ApproveExtensionRequestCommandHandler( cancellationToken ); if (!extendResult.IsSuccess) - return extendResult; - - await BillingConfigResumer.ResumeConfigsAsync( - dbContext, - gateway, - notifier, - logger, - ticket.UserId, - newPaidUntil, - cancellationToken - ); + return Result.Failure<(SupportTicket, DateTimeOffset)>(extendResult.Error); ticket.Resolve(); @@ -81,14 +113,6 @@ public sealed class ApproveExtensionRequestCommandHandler( ) ); - await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); - await telegramNotifier.NotifyUserAsync( - ticket.UserId, - $"✅ Заявка на продление одобрена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.", - $"/support?ticket={ticket.Id}", - cancellationToken - ); - - return Result.Success(); + return Result.Success((ticket, newPaidUntil)); } } diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs index 89cf324..2881377 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs @@ -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; @@ -10,6 +11,12 @@ using PnvPanel.Domain.Support; namespace PnvPanel.Application.Admin.Support; +/// +/// Проверка статуса + создание/назначение роли + Resolve() + доплата (если роль подорожала) — под +/// AdvisoryLock (по Id тикета): без неё одобрение с сайта, гонящееся с одобрением из Telegram по одной +/// и той же заявке, могли бы оба пройти проверку "ещё не решена" и оба создать RoleChangeTopUp — +/// двойной счёт за один апгрейд. +/// public sealed class ApproveRoleRequestCommandHandler( IAppDbContext dbContext, IRoleService roleService, @@ -27,18 +34,56 @@ public sealed class ApproveRoleRequestCommandHandler( 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> ApproveAsync( + Guid ticketId, + Guid adminId, + CancellationToken cancellationToken + ) + { var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( - t => t.Id == command.TicketId, + t => t.Id == ticketId, cancellationToken ); if (ticket is null) - return Result.Failure(SupportErrors.NotFound); + return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotFound); if (ticket.Type != TicketType.RoleRequest) - return Result.Failure(SupportErrors.NotRoleRequest); + return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotRoleRequest); if (ticket.Status != TicketStatus.Open) - return Result.Failure(SupportErrors.NotOpen); + return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotOpen); // Снимаем профиль ДО смены роли — нужен старый MaxConfigs/BillingPaidUntil для проратированной // доплаты за апгрейд (см. ниже), после ChangeUserRoleAsync эти данные уже недоступны. @@ -50,7 +95,7 @@ public sealed class ApproveRoleRequestCommandHandler( var roles = await roleService.ListRolesAsync(cancellationToken); var found = roles.FirstOrDefault(r => r.Id == existingRoleId); if (found is null) - return Result.Failure(SupportErrors.NotFound); + return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotFound); newRole = found; } @@ -64,7 +109,7 @@ public sealed class ApproveRoleRequestCommandHandler( cancellationToken ); if (!createResult.IsSuccess) - return Result.Failure(createResult.Error); + return Result.Failure<(SupportTicket, int?)>(createResult.Error); newRole = createResult.Value; } @@ -75,7 +120,7 @@ public sealed class ApproveRoleRequestCommandHandler( cancellationToken ); if (!assignResult.IsSuccess) - return assignResult; + return Result.Failure<(SupportTicket, int?)>(assignResult.Error); ticket.Resolve(); @@ -90,25 +135,26 @@ public sealed class ApproveRoleRequestCommandHandler( ) ); - await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); - await telegramNotifier.NotifyUserAsync( - ticket.UserId, - "✅ Ваша заявка на роль одобрена.", - $"/support?ticket={ticket.Id}", - cancellationToken - ); - + int? topUpAmount = null; if (newRole.BillingEnabled && oldProfile?.BillingPaidUntil is { } paidUntil) - await CreateTopUpIfNeededAsync(ticket.UserId, oldProfile.MaxConfigs, newRole.MaxConfigs, paidUntil, cancellationToken); + { + topUpAmount = await CreateTopUpIfNeededAsync( + ticket.UserId, + oldProfile.MaxConfigs, + newRole.MaxConfigs, + paidUntil, + cancellationToken + ); + } - return Result.Success(); + return Result.Success((ticket, topUpAmount)); } /// Роль подорожала, а оплаченный период ещё активен — по-хорошему пользователь должен /// доплатить разницу, а не доиграть апгрейд бесплатно до конца уже оплаченного срока. Роль меняется /// сразу (см. выше); доплата решается отдельно через обычный флоу PaymentRequest — см. - /// domain-model.md#rolechangetopup. - private async Task CreateTopUpIfNeededAsync( + /// domain-model.md#rolechangetopup. Только БД — уведомление шлёт вызывающий код после снятия лока. + private async Task CreateTopUpIfNeededAsync( Guid userId, int oldMaxConfigs, int newMaxConfigs, @@ -118,7 +164,7 @@ public sealed class ApproveRoleRequestCommandHandler( { var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); if (pricing?.PricePerConfigPerQuarter is not { } rate) - return; + return null; var tiers = await dbContext .PricingDiscountTiers.AsNoTracking() @@ -134,15 +180,9 @@ public sealed class ApproveRoleRequestCommandHandler( DateTimeOffset.UtcNow ); if (amount is not { } topUpAmount) - return; + return null; dbContext.PaymentRequests.Add(PaymentRequest.CreateRoleChangeTopUp(userId, topUpAmount)); - - await telegramNotifier.NotifyUserAsync( - userId, - $"💳 Новая роль дороже прежней — требуется доплата {topUpAmount} ₽ за оставшуюся часть оплаченного периода.", - "/billing", - cancellationToken - ); + return topUpAmount; } } diff --git a/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs index c94f860..a48f8c5 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs @@ -31,6 +31,9 @@ public sealed class CloseTicketCommandHandler( if (ticket is null) return Result.Failure(SupportErrors.NotFound); + if (ticket.Type != TicketType.BugReport) + return Result.Failure(SupportErrors.OnlyBugReportCanBeClosedDirectly); + if (ticket.Status == TicketStatus.Closed) return Result.Failure(SupportErrors.AlreadyClosed); diff --git a/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs index 9b2f640..3581b6f 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs @@ -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; @@ -9,6 +10,8 @@ using PnvPanel.Domain.Support; namespace PnvPanel.Application.Admin.Support; +/// Проверка статуса + Close() — под AdvisoryLock (по Id тикета), см. +/// ApproveRoleRequestCommandHandler для полного обоснования. public sealed class RejectExtensionRequestCommandHandler( IAppDbContext dbContext, IRealtimeNotifier notifier, @@ -24,23 +27,36 @@ public sealed class RejectExtensionRequestCommandHandler( if (currentUser.UserId is not { } adminId) return Result.Failure(AuthErrors.Unauthorized); - var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( - t => t.Id == command.TicketId, + 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(SupportErrors.NotFound); + + if (fresh.Type != TicketType.ExtensionRequest) + return Result.Failure(SupportErrors.NotExtensionRequest); + + if (fresh.Status == TicketStatus.Closed) + return Result.Failure(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 (ticket is null) - return Result.Failure(SupportErrors.NotFound); + if (!claimed.IsSuccess) + return Result.Failure(claimed.Error); - if (ticket.Type != TicketType.ExtensionRequest) - return Result.Failure(SupportErrors.NotExtensionRequest); - - if (ticket.Status == TicketStatus.Closed) - return Result.Failure(SupportErrors.AlreadyClosed); - - if (!string.IsNullOrWhiteSpace(command.Reason)) - dbContext.TicketComments.Add(TicketComment.Create(ticket.Id, adminId, command.Reason)); - - ticket.Close(); + var ticket = claimed.Value; dbContext.AuditLogs.Add( AuditLog.Create( diff --git a/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs index d5461c8..95eab0c 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs @@ -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; @@ -9,6 +10,8 @@ using PnvPanel.Domain.Support; namespace PnvPanel.Application.Admin.Support; +/// Проверка статуса + Close() — под AdvisoryLock (по Id тикета), см. +/// ApproveRoleRequestCommandHandler для полного обоснования. public sealed class RejectRoleRequestCommandHandler( IAppDbContext dbContext, IRealtimeNotifier notifier, @@ -24,23 +27,36 @@ public sealed class RejectRoleRequestCommandHandler( if (currentUser.UserId is not { } adminId) return Result.Failure(AuthErrors.Unauthorized); - var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( - t => t.Id == command.TicketId, + 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(SupportErrors.NotFound); + + if (fresh.Type != TicketType.RoleRequest) + return Result.Failure(SupportErrors.NotRoleRequest); + + if (fresh.Status == TicketStatus.Closed) + return Result.Failure(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 (ticket is null) - return Result.Failure(SupportErrors.NotFound); + if (!claimed.IsSuccess) + return Result.Failure(claimed.Error); - if (ticket.Type != TicketType.RoleRequest) - return Result.Failure(SupportErrors.NotRoleRequest); - - if (ticket.Status == TicketStatus.Closed) - return Result.Failure(SupportErrors.AlreadyClosed); - - if (!string.IsNullOrWhiteSpace(command.Reason)) - dbContext.TicketComments.Add(TicketComment.Create(ticket.Id, adminId, command.Reason)); - - ticket.Close(); + var ticket = claimed.Value; dbContext.AuditLogs.Add( AuditLog.Create( diff --git a/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs index ca9f453..388deb0 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs @@ -31,6 +31,9 @@ public sealed class ResolveTicketCommandHandler( if (ticket is null) return Result.Failure(SupportErrors.NotFound); + if (ticket.Type != TicketType.BugReport) + return Result.Failure(SupportErrors.OnlyBugReportCanBeResolvedDirectly); + if (ticket.Status != TicketStatus.Open) return Result.Failure(SupportErrors.NotOpen); diff --git a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs index 037501d..104f74d 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs @@ -1,10 +1,12 @@ +using Microsoft.EntityFrameworkCore; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Billing; namespace PnvPanel.Application.Admin.Users; -public sealed class ListUsersQueryHandler(IIdentityService identityService) +public sealed class ListUsersQueryHandler(IIdentityService identityService, IAppDbContext dbContext) : IQueryHandler>> { public async Task>> Handle( @@ -21,6 +23,29 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService) query.Search, cancellationToken ); - return Result.Success(result); + + // IIdentityService ничего не знает про PaymentRequests (Identity не должен на них ссылаться) — + // подмешиваем "заявка на проверке" здесь, чтобы админский список видел ту же защиту от + // тревожного "Истекло", что уже показывается пользователю на /billing (PaidUntilBadge.pendingReview). + var userIds = result.Items.Select(u => u.Id).ToList(); + var pendingUserIds = await dbContext + .PaymentRequests.Where(r => + userIds.Contains(r.UserId) + && r.Kind == PaymentRequestKind.Subscription + && r.Status == PaymentRequestStatus.AwaitingConfirmation + ) + .Select(r => r.UserId) + .ToListAsync(cancellationToken); + if (pendingUserIds.Count == 0) + return Result.Success(result); + + var pendingSet = pendingUserIds.ToHashSet(); + var enrichedItems = result + .Items.Select(u => pendingSet.Contains(u.Id) ? u with { BillingPendingReview = true } : u) + .ToList(); + + return Result.Success( + new PagedList(enrichedItems, result.Total, result.Page, result.PageSize) + ); } } diff --git a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs index 4f39d78..3b0a34c 100644 --- a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs @@ -29,6 +29,11 @@ public sealed class RefreshCommandHandler( if (profile is null) return Result.Failure(AuthErrors.InvalidRefreshToken); + // Логин отказывает заблокированному сразу — refresh обязан делать то же самое, иначе + // блокировка не имеет практического эффекта, пока у пользователя жив refresh-токен. + if (profile.IsBlocked) + return Result.Failure(AuthErrors.UserBlocked); + var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role); var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser); diff --git a/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs b/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs index 4cca6db..b064c94 100644 --- a/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs +++ b/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs @@ -60,6 +60,8 @@ public static class BillingConfigResumer config.SetBillingExpiry(newPaidUntil); } + + await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken); } /// Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим @@ -71,6 +73,7 @@ public static class BillingConfigResumer public static async Task ProtectPendingConfigsAsync( IAppDbContext dbContext, IXuiPanelGateway gateway, + IRealtimeNotifier notifier, ILogger logger, Guid userId, CancellationToken cancellationToken @@ -94,6 +97,8 @@ public static class BillingConfigResumer cancellationToken ); } + + await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken); } /// Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной @@ -139,6 +144,8 @@ public static class BillingConfigResumer cancellationToken ); } + + await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken); } private static Task> ActiveOrExpiredConfigsAsync( diff --git a/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs index b91a7da..58ec948 100644 --- a/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs @@ -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; @@ -8,6 +9,9 @@ using PnvPanel.Domain.Pricing; namespace PnvPanel.Application.Billing.CreatePaymentRequest; +/// Проверка "нет активной заявки" + создание — под AdvisoryLock (по UserId): без неё +/// два одновременных запроса (двойной клик, повтор при таймауте) оба могли бы пройти проверку и +/// создать по заявке каждый — а дальше их обе можно независимо подтвердить, продлив оплату дважды. public sealed class CreatePaymentRequestCommandHandler( IAppDbContext dbContext, IIdentityService identityService, @@ -32,21 +36,6 @@ public sealed class CreatePaymentRequestCommandHandler( if (profile.MaxConfigs == RoleQuota.Unlimited) return Result.Failure(BillingErrors.UnlimitedRoleNotSupported); - // RoleChangeTopUp не считается активной подписной заявкой — доплата за смену роли не должна - // мешать пользователю продлить/оформить обычную подписку. - var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync( - r => - r.UserId == userId - && r.Kind == PaymentRequestKind.Subscription - && ( - r.Status == PaymentRequestStatus.AwaitingPayment - || r.Status == PaymentRequestStatus.AwaitingConfirmation - ), - cancellationToken - ); - if (hasActiveRequest) - return Result.Failure(BillingErrors.ActiveRequestExists); - var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); if (pricing is null) return Result.Failure(BillingErrors.PricingNotConfigured); @@ -69,9 +58,35 @@ public sealed class CreatePaymentRequestCommandHandler( var amount = PricingDiscount.Apply(rate * profile.MaxConfigs * command.Period.ToMonths(), discountPercent); - var request = PaymentRequest.Create(userId, command.Period, amount); - dbContext.PaymentRequests.Add(request); + var claimed = await AdvisoryLock.RunAsync( + dbContext, + userId, + async lockedCancellationToken => + { + // RoleChangeTopUp не считается активной подписной заявкой — доплата за смену роли не + // должна мешать пользователю продлить/оформить обычную подписку. + var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync( + r => + r.UserId == userId + && r.Kind == PaymentRequestKind.Subscription + && ( + r.Status == PaymentRequestStatus.AwaitingPayment + || r.Status == PaymentRequestStatus.AwaitingConfirmation + ), + lockedCancellationToken + ); + if (hasActiveRequest) + return Result.Failure(BillingErrors.ActiveRequestExists); - return Result.Success(PaymentRequestDto.FromDomain(request)); + var request = PaymentRequest.Create(userId, command.Period, amount); + dbContext.PaymentRequests.Add(request); + return Result.Success(request); + }, + cancellationToken + ); + + return claimed.IsSuccess + ? Result.Success(PaymentRequestDto.FromDomain(claimed.Value)) + : Result.Failure(claimed.Error); } } diff --git a/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs index c25e9e9..75fc6b8 100644 --- a/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs @@ -12,6 +12,7 @@ public sealed class MarkPaymentSentCommandHandler( IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, + IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser, ILogger logger @@ -48,6 +49,7 @@ public sealed class MarkPaymentSentCommandHandler( await BillingConfigResumer.ProtectPendingConfigsAsync( dbContext, gateway, + notifier, logger, userId, cancellationToken diff --git a/backend/src/PnvPanel.Application/Common/Concurrency/AdvisoryLock.cs b/backend/src/PnvPanel.Application/Common/Concurrency/AdvisoryLock.cs new file mode 100644 index 0000000..3fe2dca --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Concurrency/AdvisoryLock.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Common.Concurrency; + +/// +/// pg_advisory_xact_lock-обёртка для "проверил статус — потом изменил" операций (одобрение/отклонение +/// заявок и оплат), где два параллельных запроса по одному и тому же ключу (Id тикета/заявки) иначе +/// могут оба пройти проверку статуса и оба выполнить мутацию — например, одобрение одной и той же +/// заявки одновременно с сайта и из Telegram, оба независимо начисляющих дни/роль. Паттерн взят из +/// CreateVpnConfigCommandHandler.ReserveQuotaSlotAsync. +/// +/// На нерелационном провайдере (EF Core InMemory в PnvPanel.Application.Tests — транзакции/raw SQL им +/// не поддерживаются) лок пропускается, выполняется напрямую: реальная +/// конкурентная гонка на InMemory всё равно невоспроизводима (тест однопоточный, изоляции транзакций +/// нет), а бизнес-логика внутри action всё равно должна быть покрыта тестами. Само по себе +/// сериализующее поведение лока (что второй параллельный вызов ждёт и видит уже закоммиченное +/// состояние) проверяется только PnvPanel.IntegrationTests на реальном Postgres. +/// +/// +/// Держите коротким — только проверка статуса и мутация сущности. Внешний +/// I/O (Telegram, гейтвей 3x-ui) должен идти ПОСЛЕ RunAsync, не внутри — иначе лок держит соединение к +/// БД открытым на время сетевого вызова. +/// +/// +public static class AdvisoryLock +{ + public static async Task RunAsync( + IAppDbContext dbContext, + Guid key, + Func> action, + CancellationToken cancellationToken + ) + { + if (!dbContext.Database.IsRelational()) + { + var bypassResult = await action(cancellationToken); + if (bypassResult.IsSuccess) + await dbContext.SaveChangesAsync(cancellationToken); + return bypassResult; + } + + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await AcquireAsync(dbContext, key, cancellationToken); + + var result = await action(cancellationToken); + if (!result.IsSuccess) + { + await transaction.RollbackAsync(cancellationToken); + return result; + } + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return result; + } + + public static async Task> RunAsync( + IAppDbContext dbContext, + Guid key, + Func>> action, + CancellationToken cancellationToken + ) + { + if (!dbContext.Database.IsRelational()) + { + var bypassResult = await action(cancellationToken); + if (bypassResult.IsSuccess) + await dbContext.SaveChangesAsync(cancellationToken); + return bypassResult; + } + + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await AcquireAsync(dbContext, key, cancellationToken); + + var result = await action(cancellationToken); + if (!result.IsSuccess) + { + await transaction.RollbackAsync(cancellationToken); + return result; + } + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return result; + } + + private static Task AcquireAsync(IAppDbContext dbContext, Guid key, CancellationToken cancellationToken) => + dbContext.Database.ExecuteSqlInterpolatedAsync( + $"SELECT pg_advisory_xact_lock(hashtext({key.ToString()}))", + cancellationToken + ); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs index 2c7c068..dee433f 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -36,7 +36,11 @@ public sealed record UserSummaryDto( bool IsBlocked, DateTimeOffset? ActivatedAt, bool BillingEnabled, - DateTimeOffset? BillingPaidUntil + DateTimeOffset? BillingPaidUntil, + /// Есть Subscription-заявка на оплату в AwaitingConfirmation — конфиги не гасятся, пока + /// админ не решит (см. BillingService). Заполняется в ListUsersQueryHandler (не здесь — Identity + /// не должен знать про PaymentRequests), false по умолчанию для мест, не подгружающих это поле. + bool BillingPendingReview = false ); public sealed record UserStatsDto(int Total, int Activated); diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs index a50f928..307177b 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs @@ -62,4 +62,9 @@ public interface IRealtimeNotifier /// Новый комментарий или смена статуса — пушится автору тикета (не всем участникам треда). Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken); + + /// PaidUntil/BillingSuspended или защита на время проверки заявки изменились — фронт + /// (страница /billing) инвалидирует свой запрос статуса. Без конкретных данных в пейлоаде — + /// клиент сам перезапросит актуальное состояние (см. BillingConfigResumer). + Task NotifyBillingStatusChangedAsync(Guid userId, CancellationToken cancellationToken); } diff --git a/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs index 1005a02..4c8e15e 100644 --- a/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs +++ b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs @@ -35,4 +35,9 @@ public static class ConfigErrors "Configs.BillingRequired", "Требуется оплата подписки — оформите заявку на оплату в разделе «Оплата»." ); + + public static readonly Error RotateFailed = Error.Failure( + "Configs.RotateFailed", + "Не удалось сохранить перевыпущенный конфиг. Попробуйте повторить операцию позже." + ); } diff --git a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs index 5bbba25..82b548e 100644 --- a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs @@ -67,7 +67,25 @@ public sealed class RotateVpnConfigCommandHandler( var oldClientExternalId = config.ClientExternalId; config.Rotate(newClientEmail, addResult.Value); - await dbContext.SaveChangesAsync(cancellationToken); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // Новый клиент уже создан в панели, но локально сохранить не вышло (сбой БД) — без + // компенсации это осиротевший рабочий клиент, никак не привязанный ни к одному конфигу: + // откатываем его создание, чтобы не оставлять "чужой" доступ висеть в панели. + await gateway.RemoveClientAsync( + node, + inbound.RemoteInboundId, + addResult.Value, + config.Protocol, + cancellationToken + ); + return Result.Failure(ConfigErrors.RotateFailed); + } // Старого клиента удаляем ПОСЛЕ коммита нового состояния: если удаление не выйдет, // у пользователя просто останется лишний нерабочий-для-него клиент в панели — не критично. diff --git a/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs index 39c4238..39c3872 100644 --- a/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs @@ -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; +/// Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см. +/// CreatePaymentRequestCommandHandler для полного обоснования. public sealed class CreateExtensionRequestTicketCommandHandler( IAppDbContext dbContext, IIdentityService identityService, @@ -31,22 +34,35 @@ public sealed class CreateExtensionRequestTicketCommandHandler( if (!profile.BillingEnabled) return Result.Failure(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(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(claimed.Error); + var (ticket, comment) = claimed.Value; var userName = currentUser.UserName ?? userId.ToString(); await notifier.NotifyTicketCreatedAsync( diff --git a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs index ad284b7..b58aebe 100644 --- a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs @@ -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; +/// Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см. +/// CreatePaymentRequestCommandHandler для полного обоснования. 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(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(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(SupportErrors.RoleNotFound); if (role.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase)) return Result.Failure(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(claimed.Error); + var (ticket, comment) = claimed.Value; var userName = currentUser.UserName ?? userId.ToString(); var roleDescription = requestedRoleName diff --git a/backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommandHandler.cs index 9704271..6435a15 100644 --- a/backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommandHandler.cs @@ -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); diff --git a/backend/src/PnvPanel.Application/Support/SupportErrors.cs b/backend/src/PnvPanel.Application/Support/SupportErrors.cs index 0f70b87..131182a 100644 --- a/backend/src/PnvPanel.Application/Support/SupportErrors.cs +++ b/backend/src/PnvPanel.Application/Support/SupportErrors.cs @@ -38,6 +38,26 @@ public static class SupportErrors "Переоткрыть можно только решённый тикет." ); + /// Заявка на роль/продление решается только через approve/reject — там же выполняется + /// сама выдача роли/дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего + /// не выдав пользователю. + public static readonly Error OnlyBugReportCanBeResolvedDirectly = Error.Validation( + "Support.OnlyBugReportCanBeResolvedDirectly", + "Заявку на роль или продление можно только одобрить/отклонить, а не решить напрямую." + ); + + public static readonly Error OnlyBugReportCanBeClosedDirectly = Error.Validation( + "Support.OnlyBugReportCanBeClosedDirectly", + "Заявку на роль или продление можно только одобрить/отклонить, а не закрыть напрямую." + ); + + /// Заявка на роль/продление одноразовая: повторное одобрение начислило бы дни/роль ещё + /// раз. Новый запрос — новый тикет, не переоткрытие старого. + public static readonly Error OnlyBugReportCanBeReopened = Error.Validation( + "Support.OnlyBugReportCanBeReopened", + "Заявку на роль или продление нельзя переоткрыть — оформите новую." + ); + public static readonly Error RoleRequestAlreadyPending = Error.Conflict( "Support.RoleRequestAlreadyPending", "У вас уже есть необработанная заявка на роль." diff --git a/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQueryHandler.cs b/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQueryHandler.cs index c492b9f..b2c3cb3 100644 --- a/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQueryHandler.cs @@ -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; @@ -11,6 +12,9 @@ namespace PnvPanel.Application.Telegram; /// Формально Query, но при первом наблюдении Approved-статуса атомарно "забирает" вход: /// выпускает JWT и переводит запрос в Consumed (одноразовый claim), см. api-design.md. /// Осознанное отступление от чистого CQRS ради простого поллинга без отдельного claim-эндпоинта. +/// Claim — под AdvisoryLock (по Id запроса): без неё два одновременных поллинга (два открытых окна) +/// оба могли бы увидеть Approved до того, как первый допишет Consume(), и оба выпустить валидную пару +/// токенов из одного подтверждения. /// public sealed class GetLoginRequestStatusQueryHandler( IAppDbContext dbContext, @@ -37,18 +41,48 @@ public sealed class GetLoginRequestStatusQueryHandler( if (request.Status != TelegramLoginStatus.Approved) return Result.Success(new LoginRequestStatusDto(request.Status, null)); - var profile = await identityService.GetProfileAsync( - request.UserId!.Value, + var claimed = await AdvisoryLock.RunAsync( + dbContext, + query.RequestId, + async lockedCancellationToken => + { + var fresh = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync( + r => r.Id == query.RequestId, + lockedCancellationToken + ); + if (fresh is null || fresh.Status != TelegramLoginStatus.Approved) + return Result.Failure(TelegramErrors.LoginRequestAlreadyClaimed); + + fresh.Consume(); + return Result.Success(fresh.UserId!.Value); + }, cancellationToken ); + if (!claimed.IsSuccess) + { + // Проиграли гонку — запрос уже забрал кто-то другой (обычно второй открытый poll той же + // вкладки/окна). Не ошибка для клиента — отдаём актуальный статус без токенов, как при + // обычном повторном поллинге уже потреблённого запроса. + var current = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync( + r => r.Id == query.RequestId, + cancellationToken + ); + return Result.Success( + new LoginRequestStatusDto(current?.Status ?? TelegramLoginStatus.Consumed, null) + ); + } + + var profile = await identityService.GetProfileAsync(claimed.Value, cancellationToken); if (profile is null) return Result.Failure(AuthErrors.Unauthorized); + // Обычный логин отказывает заблокированному сразу — passwordless-вход через Telegram обязан + // делать то же самое, иначе блокировка полностью обходится этим путём. + if (profile.IsBlocked) + return Result.Failure(AuthErrors.UserBlocked); + var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role); var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser); - - request.Consume(); - // IssueAsync сохраняет весь SaveChanges (в т.ч. Consume() выше) — см. RefreshTokenService. var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken); // Пользователь только что подтвердил вход через бота — Telegram точно привязан. diff --git a/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs b/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs index 346534d..ec38600 100644 --- a/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs +++ b/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs @@ -14,6 +14,11 @@ public static class TelegramErrors "Запрос на вход не найден." ); + public static readonly Error LoginRequestAlreadyClaimed = Error.Conflict( + "Telegram.LoginRequestAlreadyClaimed", + "Запрос на вход уже обработан." + ); + public static readonly Error NotLinked = Error.Conflict( "Telegram.NotLinked", "Telegram не привязан ни к одному аккаунту." diff --git a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs index 59c60c2..d87c4eb 100644 --- a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs +++ b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs @@ -51,17 +51,27 @@ public sealed class Inbound : Entity Remark = remark; Port = port; LastSyncAt = DateTimeOffset.UtcNow; + // Инбаунд снова пришёл в синхронизации — значит панель его видит, снимаем MarkUnavailable() + // (иначе после ЛЮБОГО временного сбоя синка — не только реального удаления — Revoke навсегда + // пропускал бы обращение к панели для конфигов на этом инбаунде, хотя клиент в панели жив). + IsAvailable = true; } public void Publish(string? displayName, IReadOnlyCollection allowedRoleIds) { IsPublished = true; + IsAvailable = true; DisplayName = displayName; AllowedRoleIds = allowedRoleIds.Distinct().ToList(); } public void Unpublish() => IsPublished = false; + /// Роль удалена админом — снимаем её из списка разрешённых, чтобы не оставлять ссылку на + /// несуществующую роль (иначе она навсегда "съедала" бы место в списке, ни на что не влияя, но и + /// не позволяя понять, что инбаунд стал доступен меньшему числу ролей, чем задумано). + public void RemoveAllowedRole(Guid roleId) => AllowedRoleIds = AllowedRoleIds.Where(id => id != roleId).ToList(); + /// /// Инбаунд пропал на панели (не пришёл в очередной синхронизации), но по нему есть конфиги — /// удалить запись нельзя (FK), поэтому помечаем недоступной вместо тихого "не опубликован": diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs index c1fef86..b23064f 100644 --- a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs @@ -75,6 +75,7 @@ public sealed class BillingService( await BillingConfigResumer.ProtectPendingConfigsAsync( dbContext, gateway, + notifier, logger, user.UserId, cancellationToken diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs index a0edf2b..5d42194 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs @@ -118,6 +118,17 @@ internal sealed class RoleService( if (usersInRole.Count > 0) return Result.Failure(RoleErrors.RoleInUse); + // Живых пользователей с этой ролью нет, но инбаунды могли всё ещё указывать её в + // AllowedRoleIds (plain uuid[], без FK) — без подчистки роль пропадёт из RoleManager, а + // "мёртвая" ссылка так и останется висеть в массиве, молча не пуская никого нового. + var affectedInbounds = await dbContext + .Inbounds.Where(i => i.AllowedRoleIds.Contains(roleId)) + .ToListAsync(cancellationToken); + foreach (var inbound in affectedInbounds) + inbound.RemoveAllowedRole(roleId); + if (affectedInbounds.Count > 0) + await dbContext.SaveChangesAsync(cancellationToken); + await roleManager.DeleteAsync(role); return Result.Success(); } diff --git a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs index c6fcda5..353e9af 100644 --- a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs +++ b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs @@ -157,6 +157,23 @@ internal sealed class XuiPanelGateway( CancellationToken cancellationToken ) { + // ThreeXui.Net трактует обновление shadowsocks-клиента как rotate password/cipher, вне + // области UpdateClient — молча ничего не меняет и возвращает false внутри себя, а наш уровень + // выше это никак не видит (XuiClient.UpdateClientAsync это глотает и просто логирует debug). + // Раньше это означало Result.Success() без единого реального изменения на панели: блокировка/ + // приостановка/возврат по оплате были no-op для SS-конфигов, хотя мы считали их применёнными. + // Явно отказываем, а не молчим — вызывающий код (Block/UnblockUserCommandHandler, + // BillingConfigResumer) увидит ошибку и залогирует предупреждение вместо ложной уверенности. + if (protocol == VpnProtocol.Shadowsocks) + { + return Result.Failure( + Error.Failure( + "Xui.ShadowsocksUpdateNotSupported", + "3x-ui/ThreeXui.Net не поддерживает изменение shadowsocks-клиента (enable/expiresAt/имя) после создания." + ) + ); + } + try { var client = GetClient(node); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs index d71f384..fed4915 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs @@ -220,6 +220,7 @@ public class ConfirmPaymentRequestCommandHandlerTests Arg.Is(d => d == activeConfig.ExpiresAt), Arg.Any() ); + await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any()); } [Fact] diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs index 1e16883..4e43cf8 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs @@ -147,6 +147,8 @@ public class RejectPaymentRequestCommandHandlerTests Arg.Any(), Arg.Any() ); + // Фронт (/billing) должен узнать о смене статуса сразу, не дожидаясь опроса. + await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any()); } [Fact] diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/CloseTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/CloseTicketCommandHandlerTests.cs index 0eab220..eee6fa5 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/CloseTicketCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/CloseTicketCommandHandlerTests.cs @@ -1,6 +1,7 @@ using NSubstitute; using PnvPanel.Application.Admin.Support; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Support; using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Domain.Support; using Xunit; @@ -65,4 +66,31 @@ public class CloseTicketCommandHandlerTests Assert.True(result.IsSuccess); Assert.Equal(TicketStatus.Closed, ticket.Status); } + + [Fact] + public async Task Handle_WhenExtensionRequest_ReturnsOnlyBugReportCanBeClosedDirectly() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var ticket = SupportTicket.CreateExtensionRequest(userId, 30); + dbContext.SupportTickets.Add(ticket); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"); + var handler = new CloseTicketCommandHandler( + dbContext, + _notifier, + _telegramNotifier, + currentUser + ); + + var result = await handler.Handle( + new CloseTicketCommand(ticket.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(SupportErrors.OnlyBugReportCanBeClosedDirectly, result.Error); + Assert.Equal(TicketStatus.Open, ticket.Status); + } } diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ResolveTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ResolveTicketCommandHandlerTests.cs index 289560c..8857cdc 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ResolveTicketCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ResolveTicketCommandHandlerTests.cs @@ -1,6 +1,7 @@ using NSubstitute; using PnvPanel.Application.Admin.Support; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Support; using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Domain.Support; using Xunit; @@ -65,4 +66,34 @@ public class ResolveTicketCommandHandlerTests Assert.True(result.IsSuccess); Assert.Equal(TicketStatus.Resolved, ticket.Status); } + + [Fact] + public async Task Handle_WhenRoleRequest_ReturnsOnlyBugReportCanBeResolvedDirectly() + { + // Заявку на роль/продление можно решить только через Approve/Reject — Resolve обходил бы + // одобрение (роль/продление так и не назначились бы), см. CLAUDE.md. + 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); + + var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"); + var handler = new ResolveTicketCommandHandler( + dbContext, + _notifier, + _telegramNotifier, + currentUser + ); + + var result = await handler.Handle( + new ResolveTicketCommand(ticket.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(SupportErrors.OnlyBugReportCanBeResolvedDirectly, result.Error); + Assert.Equal(TicketStatus.Open, ticket.Status); + } } diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs new file mode 100644 index 0000000..86a80e2 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs @@ -0,0 +1,81 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Billing; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Users; + +public class ListUsersQueryHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + + private static UserSummaryDto Summary(Guid id) => + new(id, "alice", "premium", true, false, DateTimeOffset.UtcNow, true, DateTimeOffset.UtcNow.AddDays(-1)); + + [Fact] + public async Task Handle_WhenUserHasAwaitingConfirmationSubscriptionRequest_SetsBillingPendingReview() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000); + request.MarkPaymentSent(); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .ListUsersAsync(1, 20, null, Arg.Any()) + .Returns(new PagedList([Summary(userId)], 1, 1, 20)); + + var handler = new ListUsersQueryHandler(_identityService, dbContext); + + var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(result.Value.Items.Single().BillingPendingReview); + } + + [Fact] + public async Task Handle_WhenNoPendingRequest_LeavesBillingPendingReviewFalse() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + + _identityService + .ListUsersAsync(1, 20, null, Arg.Any()) + .Returns(new PagedList([Summary(userId)], 1, 1, 20)); + + var handler = new ListUsersQueryHandler(_identityService, dbContext); + + var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(result.Value.Items.Single().BillingPendingReview); + } + + [Fact] + public async Task Handle_WhenPendingRequestIsRoleChangeTopUp_LeavesBillingPendingReviewFalse() + { + // RoleChangeTopUp — доплата за смену роли, не подписка; не должна показывать пользователя + // как "оплата на проверке" в админке (см. CLAUDE.md про Kind). + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var request = PaymentRequest.CreateRoleChangeTopUp(userId, 500); + request.MarkPaymentSent(); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .ListUsersAsync(1, 20, null, Arg.Any()) + .Returns(new PagedList([Summary(userId)], 1, 1, 20)); + + var handler = new ListUsersQueryHandler(_identityService, dbContext); + + var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(result.Value.Items.Single().BillingPendingReview); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs index 0390bb9..e80e3bd 100644 --- a/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs @@ -100,4 +100,41 @@ public class RefreshCommandHandlerTests Assert.False(result.IsSuccess); Assert.Equal(AuthErrors.InvalidRefreshToken, result.Error); } + + [Fact] + public async Task Handle_WhenUserBlocked_ReturnsUserBlocked() + { + var userId = Guid.NewGuid(); + var profile = new CurrentUserProfile( + userId, + "alice", + Guid.NewGuid(), + "user", + IsActivated: true, + IsBlocked: true, + MaxConfigs: 3, + MaxIpLimit: RoleQuota.Unlimited, + SubscriptionToken: "sub-token", + BillingEnabled: false, + BillingPaidUntil: null, + BillingSuspended: false + ); + var rotated = new RotatedRefreshToken( + userId, + "new-refresh-token", + DateTimeOffset.UtcNow.AddDays(30) + ); + + _refreshTokenService + .RotateAsync("old-token", Arg.Any()) + .Returns(Result.Success(rotated)); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns(profile); + + var result = await CreateHandler() + .Handle(new RefreshCommand("old-token"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(AuthErrors.UserBlocked, result.Error); + _jwtTokenService.DidNotReceive().GenerateAccessToken(Arg.Any()); + } } diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs index 92a7bb1..060ad1a 100644 --- a/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs @@ -150,6 +150,9 @@ public class CreatePaymentRequestCommandHandlerTests { using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); + var pricing = PricingSettings.CreateDefault(); + pricing.Update(500, 450, 400); + dbContext.PricingSettings.Add(pricing); dbContext.PaymentRequests.Add(PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000)); await dbContext.SaveChangesAsync(CancellationToken.None); diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs index be555d2..c2e7844 100644 --- a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs @@ -16,13 +16,14 @@ public class MarkPaymentSentCommandHandlerTests { private readonly IIdentityService _identityService = Substitute.For(); private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); private readonly ILogger _logger = Substitute.For< ILogger >(); private MarkPaymentSentCommandHandler CreateHandler(IAppDbContext dbContext, Guid userId) => - new(dbContext, _identityService, _gateway, _telegramNotifier, FakeCurrentUser.Authenticated(userId, "alice"), _logger); + new(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, FakeCurrentUser.Authenticated(userId, "alice"), _logger); private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) => new( @@ -139,6 +140,7 @@ public class MarkPaymentSentCommandHandlerTests Arg.Is(d => d > DateTimeOffset.UtcNow), Arg.Any() ); + await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any()); } [Fact] diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs index 6e20236..dc74ab6 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs @@ -1,3 +1,5 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; using NSubstitute; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; @@ -5,13 +7,53 @@ using PnvPanel.Application.Common.Models; using PnvPanel.Application.Configs; using PnvPanel.Application.Configs.Rotate; using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Activation; +using PnvPanel.Domain.Apps; +using PnvPanel.Domain.Audit; +using PnvPanel.Domain.Billing; using PnvPanel.Domain.Configs; using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Instructions; +using PnvPanel.Domain.News; using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Pricing; +using PnvPanel.Domain.Support; +using PnvPanel.Domain.Telegram; +using PnvPanel.Infrastructure.Persistence; using Xunit; namespace PnvPanel.Application.Tests.Configs.Rotate; +/// Оборачивает реальный AppDbContext, но всегда фейлит SaveChangesAsync — симулирует сбой БД +/// после успешного создания клиента в панели (DbUpdateException — единственный тип, который ловит +/// RotateVpnConfigCommandHandler для компенсирующего отката). +public sealed class ThrowingSaveDbContext(AppDbContext inner) : IAppDbContext +{ + public DbSet ActivationRequests => inner.ActivationRequests; + public DbSet AuditLogs => inner.AuditLogs; + public DbSet TelegramLinkTokens => inner.TelegramLinkTokens; + public DbSet TelegramLoginRequests => inner.TelegramLoginRequests; + public DbSet Nodes => inner.Nodes; + public DbSet Inbounds => inner.Inbounds; + public DbSet VpnConfigs => inner.VpnConfigs; + public DbSet TrafficSamples => inner.TrafficSamples; + public DbSet ClientApps => inner.ClientApps; + public DbSet NewsPosts => inner.NewsPosts; + public DbSet SupportTickets => inner.SupportTickets; + public DbSet TicketComments => inner.TicketComments; + public DbSet TicketAttachments => inner.TicketAttachments; + public DbSet InstructionIntros => inner.InstructionIntros; + public DbSet InstructionTabs => inner.InstructionTabs; + public DbSet PricingSettings => inner.PricingSettings; + public DbSet PricingDiscountTiers => inner.PricingDiscountTiers; + public DbSet BillingSettings => inner.BillingSettings; + public DbSet PaymentRequests => inner.PaymentRequests; + public DatabaseFacade Database => inner.Database; + + public Task SaveChangesAsync(CancellationToken cancellationToken) => + throw new DbUpdateException("simulated failure"); +} + public class RotateVpnConfigCommandHandlerTests { private readonly IXuiPanelGateway _gateway = Substitute.For(); @@ -231,4 +273,76 @@ public class RotateVpnConfigCommandHandlerTests Assert.Equal(gatewayError, result.Error); Assert.Equal("old-external-id", config.ClientExternalId); } + + [Fact] + public async Task Handle_WhenSaveChangesFailsAfterAddClient_RemovesOrphanedClientAndReturnsRotateFailed() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(MakeProfile(userId)); + + var node = Node.Register( + "node-1", + new Uri("https://node1.example.com"), + new NodeCredentials("admin", "protected"), + null + ); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null); + config.AssignRemoteClient("old-external-id"); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.Add(config); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _gateway + .AddClientAsync( + Arg.Any(), + inbound.RemoteInboundId, + config.Protocol, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(Result.Success("new-external-id")); + + var handler = new RotateVpnConfigCommandHandler( + new ThrowingSaveDbContext(dbContext), + _gateway, + _identityService, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new RotateVpnConfigCommand(config.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(ConfigErrors.RotateFailed, result.Error); + // Осиротевший клиент, успевший создаться в панели до сбоя SaveChanges, откатывается. + await _gateway + .Received(1) + .RemoveClientAsync( + Arg.Any(), + inbound.RemoteInboundId, + "new-external-id", + config.Protocol, + Arg.Any() + ); + await _gateway + .DidNotReceive() + .RemoveClientAsync( + Arg.Any(), + inbound.RemoteInboundId, + "old-external-id", + config.Protocol, + Arg.Any() + ); + } } diff --git a/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs index 58e31ec..2039896 100644 --- a/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs @@ -83,4 +83,29 @@ public class ReopenTicketCommandHandlerTests Assert.False(result.IsSuccess); Assert.Equal(SupportErrors.NotFound, result.Error); } + + [Fact] + public async Task Handle_WhenRoleRequestResolved_ReturnsOnlyBugReportCanBeReopened() + { + // approve/reject — единственный способ решить заявку на роль/продление (см. CLAUDE.md); + // Reopen для них запрещён даже если тикет каким-то образом оказался Resolved. + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, Guid.NewGuid()); + ticket.Resolve(); + dbContext.SupportTickets.Add(ticket); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var currentUser = FakeCurrentUser.Authenticated(userId); + var handler = new ReopenTicketCommandHandler(dbContext, _telegramNotifier, currentUser); + + var result = await handler.Handle( + new ReopenTicketCommand(ticket.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(SupportErrors.OnlyBugReportCanBeReopened, result.Error); + Assert.Equal(TicketStatus.Resolved, ticket.Status); + } } diff --git a/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs index 81bd836..67a1b91 100644 --- a/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs @@ -150,4 +150,74 @@ public class GetLoginRequestStatusQueryHandlerTests Assert.False(result.IsSuccess); Assert.Equal(AuthErrors.Unauthorized, result.Error); } + + [Fact] + public async Task Handle_WhenApprovedAndUserBlocked_ReturnsUserBlockedWithoutIssuingTokens() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null); + request.Approve(userId); + dbContext.TelegramLoginRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var profile = new CurrentUserProfile( + userId, + "alice", + Guid.NewGuid(), + "user", + true, + true, + 3, + RoleQuota.Unlimited, + "sub-token", + false, + null, + false + ); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns(profile); + + var handler = CreateHandler(dbContext); + + var result = await handler.Handle( + new TelegramNs.GetLoginRequestStatusQuery(request.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(AuthErrors.UserBlocked, result.Error); + // Уже потреблён предыдущим прогоном лока — токены выпустить не успели, но повторно claim'ить нельзя. + Assert.Equal(TelegramLoginStatus.Consumed, request.Status); + await _refreshTokenService + .DidNotReceive() + .IssueAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_WhenAlreadyConsumedByAnotherPoll_ReturnsConsumedWithoutTokens() + { + // Второй поллер той же вкладки после того, как первый уже забрал вход (Consume()) — + // короткий путь ДО AdvisoryLock (status != Approved), токены повторно не выпускаются. + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var request = TelegramLoginRequest.Create(TimeSpan.FromMinutes(5), null); + request.Approve(userId); + request.Consume(); + dbContext.TelegramLoginRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = CreateHandler(dbContext); + + var result = await handler.Handle( + new TelegramNs.GetLoginRequestStatusQuery(request.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(TelegramLoginStatus.Consumed, result.Value.Status); + Assert.Null(result.Value.Auth); + await _refreshTokenService + .DidNotReceive() + .IssueAsync(Arg.Any(), Arg.Any()); + } } diff --git a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs index 91fe159..5aafdf2 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs @@ -80,4 +80,42 @@ public class InboundTests Assert.False(inbound.IsAvailable); Assert.False(inbound.IsPublished); } + + [Fact] + public void UpdateFromRemote_AfterMarkUnavailable_RestoresIsAvailable() + { + // Инбаунд пропадал с ноды, потом снова появился при следующей синхронизации — без сброса + // IsAvailable оставался бы недоступным навсегда, хотя реально снова опубликован на панели. + var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Old", 443); + inbound.MarkUnavailable(); + + inbound.UpdateFromRemote(VpnProtocol.Vless, "New", 443); + + Assert.True(inbound.IsAvailable); + } + + [Fact] + public void Publish_AfterMarkUnavailable_RestoresIsAvailable() + { + var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); + inbound.MarkUnavailable(); + + inbound.Publish("Germany", [Guid.NewGuid()]); + + Assert.True(inbound.IsAvailable); + } + + [Fact] + public void RemoveAllowedRole_RemovesOnlyMatchingRole() + { + var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); + var roleId = Guid.NewGuid(); + var otherRoleId = Guid.NewGuid(); + inbound.Publish("Germany", [roleId, otherRoleId]); + + inbound.RemoveAllowedRole(roleId); + + Assert.DoesNotContain(roleId, inbound.AllowedRoleIds); + Assert.Contains(otherRoleId, inbound.AllowedRoleIds); + } } diff --git a/docs/api-design.md b/docs/api-design.md index 4868db6..0bf3fb6 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -362,6 +362,11 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи | `newsPublished` | `{ id, title, createdAt }` | все (broadcast) | | `ticketCreated` | `{ ticketId, userId, userName, type }` | `admins` | | `ticketUpdated` | `{ ticketId }` | владельцу | +| `billingStatusChanged` | `{ userId }` | владельцу | + +`billingStatusChanged` — безадресный пинг без данных (см. `BillingConfigResumer` в +[domain-model.md](domain-model.md#синхронизация-панели-3x-ui-со-статусом-оплаты--billingconfigresumer)): +клиент в ответ инвалидирует `my-billing-status`, а не читает payload. ### Client → Server Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по diff --git a/docs/domain-model.md b/docs/domain-model.md index 2a11de4..81aae34 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -83,7 +83,10 @@ AppUser `RemoteInboundId` — 3x-ui не переиспользует id — накапливался бы визуальный дубль). Если конфиги есть — удалить нельзя (FK), инбаунд помечается `MarkUnavailable()` (`IsAvailable=false`, `IsPublished=false`): новые конфиги на нём не создать, а `Revoke` для существующих конфигов не бьёт -в панель повторно (см. `RevokeVpnConfigCommandHandler`), а отзывает локально. +в панель повторно (см. `RevokeVpnConfigCommandHandler`), а отзывает локально. Если инбаунд позже +снова появляется в ответе 3x-ui (`UpdateFromRemote`) или переопубликовывается (`Publish`) — +`IsAvailable` сбрасывается обратно в `true`: без этого разово пропавший инбаунд оставался бы +недоступным навсегда, даже вернувшись на панель. > `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический > индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать @@ -119,10 +122,22 @@ AppUser `IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация). - **Проверка квоты выполняется под `pg_advisory_xact_lock(hashtext(userId))`** в транзакции создания - (`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли. + (`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли. Тот + же паттерн обобщён в `Application/Common/Concurrency/AdvisoryLock.cs` (`AdvisoryLock.RunAsync`) и + используется во всех "проверил статус — потом изменил" хендлерах одобрения/отклонения заявок и + оплат (`Confirm`/`RejectPaymentRequestCommandHandler`, `Approve`/`RejectRoleRequestCommandHandler`, + `Approve`/`RejectExtensionRequestCommandHandler`, `Create`/`GetLoginRequestStatusQueryHandler`, + `Create...RequestTicket`/`CreatePaymentRequestCommandHandler`) — без него параллельное одобрение той + же заявки с сайта и из Telegram могло бы оба пройти проверку статуса и оба начислить дни/роль/оплату. + На нерелационном EF-провайдере (InMemory в `PnvPanel.Application.Tests`) лок автоматически + пропускается — сериализующее поведение проверяется только в `PnvPanel.IntegrationTests` (реальный + Postgres). - `Revoke()` → статус `Revoked` (запись остаётся для истории/аудита); хендлер отдельно удаляет клиента в 3x-ui. - `Rotate(newClientEmail, newClientExternalId)` → перевыпуск: хендлер создаёт нового клиента в 3x-ui, удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки. + Если после успешного создания нового клиента в панели `SaveChangesAsync` падает (сбой БД) — + хендлер откатывает созданного клиента через `RemoveClientAsync` и возвращает `RotateFailed`, не + оставляя осиротевшего рабочего клиента, ни к одному конфигу не привязанного. - `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера). - `Suspend()`/`Resume()` → меняют статус (`Active ↔ Expired`), отдельно от `Disable()`/`Enable()` — @@ -135,6 +150,14 @@ AppUser трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp` всегда `null` — «не менять»). - `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения. +- **Shadowsocks не поддерживает обновление клиента после создания** — ограничение `ThreeXui.Net` + (`XuiClient.UpdateClient` тихо не выполняет изменение для SS, но раньше сама библиотека всё равно + отдавала «успех»). `XuiPanelGateway.UpdateClientAsync` теперь явно возвращает `Result.Failure` + для `VpnProtocol.Shadowsocks` — не пытается вызвать панель зря и не врёт об успехе PnvPanel. На + практике это значит: `Disable`/`Enable` (блокировка админом), приостановка/возврат за неуплату + (`BillingConfigResumer`) и продление `expiresAt` **не долетают до панели** для SS-конфигов — только + в локальную БД. До появления реальной поддержки в `ThreeXui.Net` SS — известное ограничение, не + скрытое молчаливым сбоем. - **Доступ разрешён только активированному пользователю** (`AppUser.IsActivated == true`): создание, просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline @@ -314,6 +337,12 @@ UI **настойчиво напоминает** привязать его (ед одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не плодить тикеты, которые некому обработать, если админ единственный. +**Удаление роли** (`IRoleService.DeleteRoleAsync`): запрещено для системных ролей и пока есть живые +пользователи с этой ролью (`RoleErrors.RoleInUse`). Живых пользователей нет — но `Inbound.AllowedRoleIds` +(plain `uuid[]`, без FK) мог всё ещё указывать удаляемую роль; перед `RoleManager.DeleteAsync` +хендлер подчищает такие ссылки (`Inbound.RemoveAllowedRole`), иначе "мёртвый" Id молча оставался бы +висеть в массиве — не пуская никого нового, но и не давая понять почему. + ### PricingSettings — глобальная справочная цена конфига Единственная строка в таблице (singleton) — цена за один конфиг, редактируется админом. Не привязана к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с @@ -538,6 +567,16 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты всё ещё лежит старое `AwaitingConfirmation` — без исключения по Id проверка ложно приняла бы её за ещё одну висящую заявку). +Каждая из трёх операций также шлёт `IRealtimeNotifier.NotifyBillingStatusChangedAsync(userId, ...)` — +безадресный SignalR-пинг (`billingStatusChanged`, группа `user:{id}`) без пейлоада данных: фронт +(`/billing`) в ответ инвалидирует свой запрос статуса, а не ждёт следующего ручного рефреша/поллинга. + +Админский список пользователей (`ListUsersQueryHandler`, `UserSummaryDto.BillingPendingReview`) +отдельно подмешивает "есть Subscription-заявка на `AwaitingConfirmation`" тем же способом, что и +`PaidUntilBadge.pendingReview` на `/billing` у самого пользователя — `IIdentityService` ничего не +знает про `PaymentRequest` (граница Identity/биллинг), поэтому джойн с `PaymentRequests` сделан в +Application-хендлере поверх результата `IIdentityService.ListUsersAsync`, а не внутри Identity. + Прочие точки, не входящие в `BillingConfigResumer`: - **Создание** (`CreateVpnConfigCommandHandler`) пушит `expiresAt = profile.BillingPaidUntil` при `BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок. @@ -605,6 +644,14 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты Переходы: `Pending → Approved/Rejected/Expired`; `Approved → Consumed` (после выпуска JWT сайту). После `Consumed`/`Expired` — не переиспользуется. +`GetLoginRequestStatusQueryHandler` (поллинг статуса с сайта) при первом наблюдении `Approved` +атомарно "забирает" вход: под `AdvisoryLock` (по Id запроса) заново проверяет статус, `Consume()`-ит +и только потом выпускает JWT — без лока два одновременных поллинга (два открытых окна той же вкладки) +могли бы оба увидеть `Approved` до того, как первый допишет `Consume()`, и оба выпустить валидную пару +токенов из одного подтверждения. Как и обычный логин — отказывает **заблокированному** пользователю +(`AppUser.IsBlocked`) до выпуска токенов; так же поступает `RefreshCommandHandler` при ротации +refresh-токена — иначе блокировка обходилась бы passwordless-входом/уже выданным refresh-токеном. + ### SupportTicket — обращение в поддержку Три вида: `BugReport` (свободная форма, с вложениями), `RoleRequest` (запрос существующей роли — кроме `admin` — либо параметров новой) и `ExtensionRequest` (продление оплаченного периода на N @@ -638,6 +685,14 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты - `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest`/ `ExtensionRequest` — отклонение. - `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается. +- **`approve`/`reject` — единственный путь решить `RoleRequest`/`ExtensionRequest`.** Общие + `/admin/support/tickets/{id}/resolve|close` (для произвольного `BugReport`) на этих двух типах + возвращают `OnlyBugReportCanBeResolvedDirectly`/`OnlyBugReportCanBeClosedDirectly` без изменения + статуса — иначе `resolve` обходил бы `ApproveRoleRequestCommandHandler`/ + `ApproveExtensionRequestCommandHandler` и переводил тикет в `Resolved`, так и не выдав роль/дни. + По той же причине `Reopen()` на `RoleRequest`/`ExtensionRequest` тоже запрещён + (`OnlyBugReportCanBeReopened`) — переоткрытие уже решённой заявки на роль не имеет осмысленного + действия (роль/дни уже выданы, откатывать их не пытаемся). - Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`) и отдельно не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`) на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`. diff --git a/frontend/src/features/admin/users/UserManageDialog.tsx b/frontend/src/features/admin/users/UserManageDialog.tsx index ea9c3e6..a81aea3 100644 --- a/frontend/src/features/admin/users/UserManageDialog.tsx +++ b/frontend/src/features/admin/users/UserManageDialog.tsx @@ -155,7 +155,7 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma {user.billingEnabled && (
- +
- {data.status === 'Resolved' && ( + {data.status === 'Resolved' && data.type === 'BugReport' && ( diff --git a/frontend/src/routes/admin/users.tsx b/frontend/src/routes/admin/users.tsx index d868ae8..e27955c 100644 --- a/frontend/src/routes/admin/users.tsx +++ b/frontend/src/routes/admin/users.tsx @@ -8,7 +8,6 @@ import { Badge } from '@/shared/ui/badge' import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge' import { listUsers } from '@/features/admin/users/api' import { UserManageDialog } from '@/features/admin/users/UserManageDialog' -import type { UserSummaryDto } from '@/shared/api/types' export const Route = createFileRoute('/admin/users')({ component: AdminUsersPage }) @@ -18,12 +17,17 @@ function AdminUsersPage() { const { t } = useTranslation() const [search, setSearch] = useState('') const [page, setPage] = useState(1) - const [managing, setManaging] = useState(null) + // Id, не сам объект — иначе диалог держит "замороженный" снимок пользователя и не видит + // изменения, сделанные им же самим (гифт/блок/смена роли инвалидируют этот запрос, но проп + // диалога от этого не обновится, если хранить готовый объект). Ищем свежую версию в live-данных + // запроса на каждый рендер. + const [managingId, setManagingId] = useState(null) const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-users', page, search], queryFn: () => listUsers(page, PAGE_SIZE, search || undefined), }) + const managingUser = managingId ? (data?.items.find((u) => u.id === managingId) ?? null) : null return (
@@ -77,13 +81,17 @@ function AdminUsersPage() { {user.billingEnabled ? ( - + ) : ( )} - @@ -109,7 +117,9 @@ function AdminUsersPage() { )} - {managing && !open && setManaging(null)} />} + {managingUser && ( + !open && setManagingId(null)} /> + )}
) } diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index ce16a6d..fcf8fc2 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -167,6 +167,9 @@ export type UserSummaryDto = { activatedAt: string | null billingEnabled: boolean billingPaidUntil: string | null + /** Есть Subscription-заявка на оплату, ожидающая решения админа — конфиги не гасятся, пока он не + * решит (см. BillingService). Бейдж должен показывать нейтральный статус, а не тревожный "Истекло". */ + billingPendingReview: boolean } export type RoleDto = { diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index eb456fc..d75cd6b 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -132,6 +132,9 @@ const resources = { requestStatus: { AwaitingPayment: 'Ожидает оплаты', AwaitingConfirmation: 'На проверке у администратора', + Confirmed: 'Подтверждена', + Rejected: 'Отклонена', + Cancelled: 'Отменена', }, requisites: 'Реквизиты для оплаты', requisitesMissing: 'Реквизиты ещё не настроены администратором.', @@ -674,6 +677,9 @@ const resources = { requestStatus: { AwaitingPayment: 'Awaiting payment', AwaitingConfirmation: 'Under admin review', + Confirmed: 'Confirmed', + Rejected: 'Rejected', + Cancelled: 'Cancelled', }, requisites: 'Payment details', requisitesMissing: 'The administrator has not set up payment details yet.', diff --git a/frontend/src/shared/realtime/RealtimeProvider.tsx b/frontend/src/shared/realtime/RealtimeProvider.tsx index 0654f34..58d79dc 100644 --- a/frontend/src/shared/realtime/RealtimeProvider.tsx +++ b/frontend/src/shared/realtime/RealtimeProvider.tsx @@ -8,6 +8,7 @@ type ConfigTrafficUpdated = { configId: string; usedUpBytes: number; usedDownByt type ConfigStatusChanged = { configId: string; status: ConfigStatus } type UserActivated = { userId: string } type NewsPublished = { id: string; title: string; createdAt: string } +type BillingStatusChanged = { userId: string } /** Живые обновления по SignalR: точечно патчит кэш TanStack Query вместо инвалидации всего списка. */ export function RealtimeProvider({ children }: { children: React.ReactNode }) { @@ -54,10 +55,15 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { void queryClient.invalidateQueries({ queryKey: ['news'] }) } + const onBillingStatusChanged = (_payload: BillingStatusChanged) => { + void queryClient.invalidateQueries({ queryKey: ['my-billing-status'] }) + } + connection.on('configTrafficUpdated', onTrafficUpdated) connection.on('configStatusChanged', onStatusChanged) connection.on('userActivated', onUserActivated) connection.on('newsPublished', onNewsPublished) + connection.on('billingStatusChanged', onBillingStatusChanged) void startConnection() @@ -66,6 +72,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { connection.off('configStatusChanged', onStatusChanged) connection.off('userActivated', onUserActivated) connection.off('newsPublished', onNewsPublished) + connection.off('billingStatusChanged', onBillingStatusChanged) } }, [user, queryClient])