Implement billing status notification and enhance user management integration
CI / Backend (build + test) (push) Successful in 1m30s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Added `NotifyBillingStatusChangedAsync` method to `IRealtimeNotifier` for notifying clients about changes in billing status.
- Updated `BillingConfigResumer` to call the new notification method after modifying billing configurations, ensuring users receive real-time updates.
- Enhanced `ListUsersQueryHandler` to include a `BillingPendingReview` property in `UserSummaryDto`, indicating if a user has a pending payment request awaiting confirmation.
- Refactored various command handlers to utilize `AdvisoryLock` for managing concurrent requests, preventing race conditions in billing operations.
- Updated tests to cover new notification behaviors and ensure proper functionality in billing status management.
This commit is contained in:
Leonid Pershin
2026-07-19 23:22:57 +03:00
parent b32756d5bc
commit e19860ba46
49 changed files with 1195 additions and 233 deletions
@@ -151,4 +151,11 @@ internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext)
.Clients.Group(GroupNames.User(userId)) .Clients.Group(GroupNames.User(userId))
.SendAsync("ticketUpdated", new { ticketId }, cancellationToken); .SendAsync("ticketUpdated", new { ticketId }, cancellationToken);
} }
public Task NotifyBillingStatusChangedAsync(Guid userId, CancellationToken cancellationToken)
{
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync("billingStatusChanged", new { userId }, cancellationToken);
}
} }
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Billing; using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -14,6 +15,9 @@ namespace PnvPanel.Application.Admin.Billing;
/// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги, /// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги,
/// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя — /// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя —
/// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка). /// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка).
/// Проверка статуса + продление PaidUntil + Confirm() — под AdvisoryLock (по Id заявки): без неё
/// конфирм с сайта, гонящийся с конфирмом из Telegram по одной и той же заявке, могли бы оба пройти
/// проверку "ещё не решена" и оба продлить PaidUntil — двойное начисление за одну оплату.
/// </summary> /// </summary>
public sealed class ConfirmPaymentRequestCommandHandler( public sealed class ConfirmPaymentRequestCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
@@ -33,29 +37,31 @@ public sealed class ConfirmPaymentRequestCommandHandler(
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync( var claimed = await AdvisoryLock.RunAsync(
r => r.Id == command.RequestId, dbContext,
command.RequestId,
lockedCancellationToken => ClaimAndConfirmAsync(command.RequestId, adminId, lockedCancellationToken),
cancellationToken cancellationToken
); );
if (request is null) if (!claimed.IsSuccess)
return Result.Failure(BillingErrors.RequestNotFound); return Result.Failure(claimed.Error);
if ( var (request, newPaidUntil) = claimed.Value;
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);
// RoleChangeTopUp — доплата разницы в цене при апгрейде роли, не покупка времени: подтверждение // RoleChangeTopUp — доплата разницы в цене при апгрейде роли, не покупка времени: подтверждение
// не трогает BillingPaidUntil и не возвращает Expired-конфиги (это делает обычная Subscription- // не возвращает Expired-конфиги (это делает обычная Subscription-оплата/продление).
// оплата/продление). Period не задан для этого Kind — ToMonths() здесь неприменим. if (request.Kind == PaymentRequestKind.Subscription)
if (request.Kind == PaymentRequestKind.RoleChangeTopUp)
{ {
request.Confirm(adminId); await BillingConfigResumer.ResumeConfigsAsync(
dbContext,
gateway,
notifier,
logger,
request.UserId,
newPaidUntil!.Value,
cancellationToken
);
}
dbContext.AuditLogs.Add( dbContext.AuditLogs.Add(
AuditLog.Create( AuditLog.Create(
@@ -68,16 +74,47 @@ public sealed class ConfirmPaymentRequestCommandHandler(
) )
); );
await telegramNotifier.NotifyUserAsync( var message =
request.UserId, request.Kind == PaymentRequestKind.RoleChangeTopUp
"✅ Доплата за смену роли подтверждена.", ? "✅ Доплата за смену роли подтверждена."
null, : $"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.";
cancellationToken await telegramNotifier.NotifyUserAsync(request.UserId, message, null, cancellationToken);
);
return Result.Success(); return Result.Success();
} }
/// <summary>Критическая секция под локом: проверка статуса, продление PaidUntil (для Subscription)
/// и сам Confirm() — всё атомарно вместе, чтобы гонка не могла продлить PaidUntil дважды. Внешний
/// I/O (гейтвей, Telegram) сюда намеренно не входит — см. AdvisoryLock.</summary>
private async Task<Result<(PaymentRequest Request, DateTimeOffset? NewPaidUntil)>> 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 now = DateTimeOffset.UtcNow;
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now; var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
var newPaidUntil = baseline.AddMonths(request.Period!.Value.ToMonths()); var newPaidUntil = baseline.AddMonths(request.Period!.Value.ToMonths());
@@ -88,38 +125,9 @@ public sealed class ConfirmPaymentRequestCommandHandler(
cancellationToken cancellationToken
); );
if (!extendResult.IsSuccess) if (!extendResult.IsSuccess)
return extendResult; return Result.Failure<(PaymentRequest, DateTimeOffset?)>(extendResult.Error);
request.Confirm(adminId); request.Confirm(adminId);
return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, newPaidUntil));
await BillingConfigResumer.ResumeConfigsAsync(
dbContext,
gateway,
notifier,
logger,
request.UserId,
newPaidUntil,
cancellationToken
);
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"PaymentConfirmed",
"PaymentRequest",
request.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await telegramNotifier.NotifyUserAsync(
request.UserId,
$"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.",
null,
cancellationToken
);
return Result.Success();
} }
} }
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Billing; using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -10,6 +11,9 @@ using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing; namespace PnvPanel.Application.Admin.Billing;
/// <summary>Проверка статуса + Reject() — под AdvisoryLock (по Id заявки), тот же приём, что и в
/// ConfirmPaymentRequestCommandHandler — без него отклонение с сайта, гонящееся с отклонением из
/// Telegram, могли бы оба пройти проверку "ещё не решена".</summary>
public sealed class RejectPaymentRequestCommandHandler( public sealed class RejectPaymentRequestCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IIdentityService identityService, IIdentityService identityService,
@@ -28,20 +32,33 @@ public sealed class RejectPaymentRequestCommandHandler(
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync( var claimed = await AdvisoryLock.RunAsync(
dbContext,
command.RequestId,
async lockedCancellationToken =>
{
var fresh = await dbContext.PaymentRequests.FirstOrDefaultAsync(
r => r.Id == command.RequestId, r => r.Id == command.RequestId,
cancellationToken lockedCancellationToken
); );
if (request is null) if (fresh is null)
return Result.Failure(BillingErrors.RequestNotFound); return Result.Failure<PaymentRequest>(BillingErrors.RequestNotFound);
if ( if (
request.Status fresh.Status
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation) is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
) )
return Result.Failure(BillingErrors.RequestNotDecidable); return Result.Failure<PaymentRequest>(BillingErrors.RequestNotDecidable);
request.Reject(adminId, command.Reason); fresh.Reject(adminId, command.Reason);
return Result.Success(fresh);
},
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure(claimed.Error);
var request = claimed.Value;
// Пока заявка висела на проверке, конфиги могли быть временно "защищены" на панели // Пока заявка висела на проверке, конфиги могли быть временно "защищены" на панели
// (ProtectPendingConfigsAsync — enable/expiresAt подвинуты вперёд без изменения локального // (ProtectPendingConfigsAsync — enable/expiresAt подвинуты вперёд без изменения локального
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Billing; using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -11,6 +12,9 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
/// <summary>Проверка статуса + продление PaidUntil + Resolve() — под AdvisoryLock (по Id тикета): без
/// неё одобрение с сайта, гонящееся с одобрением из Telegram по одной и той же заявке, могли бы оба
/// пройти проверку "ещё не решена" и оба продлить PaidUntil — двойное начисление дней.</summary>
public sealed class ApproveExtensionRequestCommandHandler( public sealed class ApproveExtensionRequestCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IIdentityService identityService, IIdentityService identityService,
@@ -29,22 +33,60 @@ public sealed class ApproveExtensionRequestCommandHandler(
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); 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<Result<(SupportTicket Ticket, DateTimeOffset NewPaidUntil)>> ApproveAsync(
Guid ticketId,
Guid adminId,
CancellationToken cancellationToken
)
{
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId, t => t.Id == ticketId,
cancellationToken cancellationToken
); );
if (ticket is null) if (ticket is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure<(SupportTicket, DateTimeOffset)>(SupportErrors.NotFound);
if (ticket.Type != TicketType.ExtensionRequest) if (ticket.Type != TicketType.ExtensionRequest)
return Result.Failure(SupportErrors.NotExtensionRequest); return Result.Failure<(SupportTicket, DateTimeOffset)>(SupportErrors.NotExtensionRequest);
if (ticket.Status != TicketStatus.Open) 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); var profile = await identityService.GetProfileAsync(ticket.UserId, cancellationToken);
if (profile is null) if (profile is null)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure<(SupportTicket, DateTimeOffset)>(AuthErrors.Unauthorized);
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now; var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
@@ -56,17 +98,7 @@ public sealed class ApproveExtensionRequestCommandHandler(
cancellationToken cancellationToken
); );
if (!extendResult.IsSuccess) if (!extendResult.IsSuccess)
return extendResult; return Result.Failure<(SupportTicket, DateTimeOffset)>(extendResult.Error);
await BillingConfigResumer.ResumeConfigsAsync(
dbContext,
gateway,
notifier,
logger,
ticket.UserId,
newPaidUntil,
cancellationToken
);
ticket.Resolve(); ticket.Resolve();
@@ -81,14 +113,6 @@ public sealed class ApproveExtensionRequestCommandHandler(
) )
); );
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); return Result.Success((ticket, newPaidUntil));
await telegramNotifier.NotifyUserAsync(
ticket.UserId,
$"✅ Заявка на продление одобрена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.",
$"/support?ticket={ticket.Id}",
cancellationToken
);
return Result.Success();
} }
} }
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -10,6 +11,12 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
/// <summary>
/// Проверка статуса + создание/назначение роли + Resolve() + доплата (если роль подорожала) — под
/// AdvisoryLock (по Id тикета): без неё одобрение с сайта, гонящееся с одобрением из Telegram по одной
/// и той же заявке, могли бы оба пройти проверку "ещё не решена" и оба создать RoleChangeTopUp —
/// двойной счёт за один апгрейд.
/// </summary>
public sealed class ApproveRoleRequestCommandHandler( public sealed class ApproveRoleRequestCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IRoleService roleService, IRoleService roleService,
@@ -27,18 +34,56 @@ public sealed class ApproveRoleRequestCommandHandler(
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var claimed = await AdvisoryLock.RunAsync(
dbContext,
command.TicketId,
lockedCancellationToken => ApproveAsync(command.TicketId, adminId, lockedCancellationToken),
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure(claimed.Error);
var (ticket, topUpAmount) = claimed.Value;
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"✅ Ваша заявка на роль одобрена.",
$"/support?ticket={ticket.Id}",
cancellationToken
);
if (topUpAmount is { } amount)
{
await telegramNotifier.NotifyUserAsync(
ticket.UserId,
$"💳 Новая роль дороже прежней — требуется доплата {amount} ₽ за оставшуюся часть оплаченного периода.",
"/billing",
cancellationToken
);
}
return Result.Success();
}
private async Task<Result<(SupportTicket Ticket, int? TopUpAmount)>> ApproveAsync(
Guid ticketId,
Guid adminId,
CancellationToken cancellationToken
)
{
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId, t => t.Id == ticketId,
cancellationToken cancellationToken
); );
if (ticket is null) if (ticket is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotFound);
if (ticket.Type != TicketType.RoleRequest) if (ticket.Type != TicketType.RoleRequest)
return Result.Failure(SupportErrors.NotRoleRequest); return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotRoleRequest);
if (ticket.Status != TicketStatus.Open) if (ticket.Status != TicketStatus.Open)
return Result.Failure(SupportErrors.NotOpen); return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotOpen);
// Снимаем профиль ДО смены роли — нужен старый MaxConfigs/BillingPaidUntil для проратированной // Снимаем профиль ДО смены роли — нужен старый MaxConfigs/BillingPaidUntil для проратированной
// доплаты за апгрейд (см. ниже), после ChangeUserRoleAsync эти данные уже недоступны. // доплаты за апгрейд (см. ниже), после ChangeUserRoleAsync эти данные уже недоступны.
@@ -50,7 +95,7 @@ public sealed class ApproveRoleRequestCommandHandler(
var roles = await roleService.ListRolesAsync(cancellationToken); var roles = await roleService.ListRolesAsync(cancellationToken);
var found = roles.FirstOrDefault(r => r.Id == existingRoleId); var found = roles.FirstOrDefault(r => r.Id == existingRoleId);
if (found is null) if (found is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotFound);
newRole = found; newRole = found;
} }
@@ -64,7 +109,7 @@ public sealed class ApproveRoleRequestCommandHandler(
cancellationToken cancellationToken
); );
if (!createResult.IsSuccess) if (!createResult.IsSuccess)
return Result.Failure(createResult.Error); return Result.Failure<(SupportTicket, int?)>(createResult.Error);
newRole = createResult.Value; newRole = createResult.Value;
} }
@@ -75,7 +120,7 @@ public sealed class ApproveRoleRequestCommandHandler(
cancellationToken cancellationToken
); );
if (!assignResult.IsSuccess) if (!assignResult.IsSuccess)
return assignResult; return Result.Failure<(SupportTicket, int?)>(assignResult.Error);
ticket.Resolve(); ticket.Resolve();
@@ -90,25 +135,26 @@ public sealed class ApproveRoleRequestCommandHandler(
) )
); );
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); int? topUpAmount = null;
await telegramNotifier.NotifyUserAsync( if (newRole.BillingEnabled && oldProfile?.BillingPaidUntil is { } paidUntil)
{
topUpAmount = await CreateTopUpIfNeededAsync(
ticket.UserId, ticket.UserId,
"✅ Ваша заявка на роль одобрена.", oldProfile.MaxConfigs,
$"/support?ticket={ticket.Id}", newRole.MaxConfigs,
paidUntil,
cancellationToken cancellationToken
); );
}
if (newRole.BillingEnabled && oldProfile?.BillingPaidUntil is { } paidUntil) return Result.Success((ticket, topUpAmount));
await CreateTopUpIfNeededAsync(ticket.UserId, oldProfile.MaxConfigs, newRole.MaxConfigs, paidUntil, cancellationToken);
return Result.Success();
} }
/// <summary>Роль подорожала, а оплаченный период ещё активен — по-хорошему пользователь должен /// <summary>Роль подорожала, а оплаченный период ещё активен — по-хорошему пользователь должен
/// доплатить разницу, а не доиграть апгрейд бесплатно до конца уже оплаченного срока. Роль меняется /// доплатить разницу, а не доиграть апгрейд бесплатно до конца уже оплаченного срока. Роль меняется
/// сразу (см. выше); доплата решается отдельно через обычный флоу PaymentRequest — см. /// сразу (см. выше); доплата решается отдельно через обычный флоу PaymentRequest — см.
/// domain-model.md#rolechangetopup.</summary> /// domain-model.md#rolechangetopup. Только БД — уведомление шлёт вызывающий код после снятия лока.</summary>
private async Task CreateTopUpIfNeededAsync( private async Task<int?> CreateTopUpIfNeededAsync(
Guid userId, Guid userId,
int oldMaxConfigs, int oldMaxConfigs,
int newMaxConfigs, int newMaxConfigs,
@@ -118,7 +164,7 @@ public sealed class ApproveRoleRequestCommandHandler(
{ {
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
if (pricing?.PricePerConfigPerQuarter is not { } rate) if (pricing?.PricePerConfigPerQuarter is not { } rate)
return; return null;
var tiers = await dbContext var tiers = await dbContext
.PricingDiscountTiers.AsNoTracking() .PricingDiscountTiers.AsNoTracking()
@@ -134,15 +180,9 @@ public sealed class ApproveRoleRequestCommandHandler(
DateTimeOffset.UtcNow DateTimeOffset.UtcNow
); );
if (amount is not { } topUpAmount) if (amount is not { } topUpAmount)
return; return null;
dbContext.PaymentRequests.Add(PaymentRequest.CreateRoleChangeTopUp(userId, topUpAmount)); dbContext.PaymentRequests.Add(PaymentRequest.CreateRoleChangeTopUp(userId, topUpAmount));
return topUpAmount;
await telegramNotifier.NotifyUserAsync(
userId,
$"💳 Новая роль дороже прежней — требуется доплата {topUpAmount} ₽ за оставшуюся часть оплаченного периода.",
"/billing",
cancellationToken
);
} }
} }
@@ -31,6 +31,9 @@ public sealed class CloseTicketCommandHandler(
if (ticket is null) if (ticket is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure(SupportErrors.NotFound);
if (ticket.Type != TicketType.BugReport)
return Result.Failure(SupportErrors.OnlyBugReportCanBeClosedDirectly);
if (ticket.Status == TicketStatus.Closed) if (ticket.Status == TicketStatus.Closed)
return Result.Failure(SupportErrors.AlreadyClosed); return Result.Failure(SupportErrors.AlreadyClosed);
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -9,6 +10,8 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
/// <summary>Проверка статуса + Close() — под AdvisoryLock (по Id тикета), см.
/// ApproveRoleRequestCommandHandler для полного обоснования.</summary>
public sealed class RejectExtensionRequestCommandHandler( public sealed class RejectExtensionRequestCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IRealtimeNotifier notifier, IRealtimeNotifier notifier,
@@ -24,23 +27,36 @@ public sealed class RejectExtensionRequestCommandHandler(
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( var claimed = await AdvisoryLock.RunAsync(
dbContext,
command.TicketId,
async lockedCancellationToken =>
{
var fresh = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId, t => t.Id == command.TicketId,
cancellationToken lockedCancellationToken
); );
if (ticket is null) if (fresh is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure<SupportTicket>(SupportErrors.NotFound);
if (ticket.Type != TicketType.ExtensionRequest) if (fresh.Type != TicketType.ExtensionRequest)
return Result.Failure(SupportErrors.NotExtensionRequest); return Result.Failure<SupportTicket>(SupportErrors.NotExtensionRequest);
if (ticket.Status == TicketStatus.Closed) if (fresh.Status == TicketStatus.Closed)
return Result.Failure(SupportErrors.AlreadyClosed); return Result.Failure<SupportTicket>(SupportErrors.AlreadyClosed);
if (!string.IsNullOrWhiteSpace(command.Reason)) if (!string.IsNullOrWhiteSpace(command.Reason))
dbContext.TicketComments.Add(TicketComment.Create(ticket.Id, adminId, command.Reason)); dbContext.TicketComments.Add(TicketComment.Create(fresh.Id, adminId, command.Reason));
ticket.Close(); fresh.Close();
return Result.Success(fresh);
},
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure(claimed.Error);
var ticket = claimed.Value;
dbContext.AuditLogs.Add( dbContext.AuditLogs.Add(
AuditLog.Create( AuditLog.Create(
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -9,6 +10,8 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
/// <summary>Проверка статуса + Close() — под AdvisoryLock (по Id тикета), см.
/// ApproveRoleRequestCommandHandler для полного обоснования.</summary>
public sealed class RejectRoleRequestCommandHandler( public sealed class RejectRoleRequestCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IRealtimeNotifier notifier, IRealtimeNotifier notifier,
@@ -24,23 +27,36 @@ public sealed class RejectRoleRequestCommandHandler(
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( var claimed = await AdvisoryLock.RunAsync(
dbContext,
command.TicketId,
async lockedCancellationToken =>
{
var fresh = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId, t => t.Id == command.TicketId,
cancellationToken lockedCancellationToken
); );
if (ticket is null) if (fresh is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure<SupportTicket>(SupportErrors.NotFound);
if (ticket.Type != TicketType.RoleRequest) if (fresh.Type != TicketType.RoleRequest)
return Result.Failure(SupportErrors.NotRoleRequest); return Result.Failure<SupportTicket>(SupportErrors.NotRoleRequest);
if (ticket.Status == TicketStatus.Closed) if (fresh.Status == TicketStatus.Closed)
return Result.Failure(SupportErrors.AlreadyClosed); return Result.Failure<SupportTicket>(SupportErrors.AlreadyClosed);
if (!string.IsNullOrWhiteSpace(command.Reason)) if (!string.IsNullOrWhiteSpace(command.Reason))
dbContext.TicketComments.Add(TicketComment.Create(ticket.Id, adminId, command.Reason)); dbContext.TicketComments.Add(TicketComment.Create(fresh.Id, adminId, command.Reason));
ticket.Close(); fresh.Close();
return Result.Success(fresh);
},
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure(claimed.Error);
var ticket = claimed.Value;
dbContext.AuditLogs.Add( dbContext.AuditLogs.Add(
AuditLog.Create( AuditLog.Create(
@@ -31,6 +31,9 @@ public sealed class ResolveTicketCommandHandler(
if (ticket is null) if (ticket is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure(SupportErrors.NotFound);
if (ticket.Type != TicketType.BugReport)
return Result.Failure(SupportErrors.OnlyBugReportCanBeResolvedDirectly);
if (ticket.Status != TicketStatus.Open) if (ticket.Status != TicketStatus.Open)
return Result.Failure(SupportErrors.NotOpen); return Result.Failure(SupportErrors.NotOpen);
@@ -1,10 +1,12 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Users; namespace PnvPanel.Application.Admin.Users;
public sealed class ListUsersQueryHandler(IIdentityService identityService) public sealed class ListUsersQueryHandler(IIdentityService identityService, IAppDbContext dbContext)
: IQueryHandler<ListUsersQuery, Result<PagedList<UserSummaryDto>>> : IQueryHandler<ListUsersQuery, Result<PagedList<UserSummaryDto>>>
{ {
public async Task<Result<PagedList<UserSummaryDto>>> Handle( public async Task<Result<PagedList<UserSummaryDto>>> Handle(
@@ -21,6 +23,29 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService)
query.Search, query.Search,
cancellationToken cancellationToken
); );
// 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); 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<UserSummaryDto>(enrichedItems, result.Total, result.Page, result.PageSize)
);
} }
} }
@@ -29,6 +29,11 @@ public sealed class RefreshCommandHandler(
if (profile is null) if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.InvalidRefreshToken); return Result.Failure<AuthResult>(AuthErrors.InvalidRefreshToken);
// Логин отказывает заблокированному сразу — refresh обязан делать то же самое, иначе
// блокировка не имеет практического эффекта, пока у пользователя жив refresh-токен.
if (profile.IsBlocked)
return Result.Failure<AuthResult>(AuthErrors.UserBlocked);
var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role); var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser); var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser);
@@ -60,6 +60,8 @@ public static class BillingConfigResumer
config.SetBillingExpiry(newPaidUntil); config.SetBillingExpiry(newPaidUntil);
} }
await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken);
} }
/// <summary>Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим /// <summary>Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим
@@ -71,6 +73,7 @@ public static class BillingConfigResumer
public static async Task ProtectPendingConfigsAsync( public static async Task ProtectPendingConfigsAsync(
IAppDbContext dbContext, IAppDbContext dbContext,
IXuiPanelGateway gateway, IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ILogger logger, ILogger logger,
Guid userId, Guid userId,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -94,6 +97,8 @@ public static class BillingConfigResumer
cancellationToken cancellationToken
); );
} }
await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken);
} }
/// <summary>Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной /// <summary>Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной
@@ -139,6 +144,8 @@ public static class BillingConfigResumer
cancellationToken cancellationToken
); );
} }
await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken);
} }
private static Task<List<VpnConfig>> ActiveOrExpiredConfigsAsync( private static Task<List<VpnConfig>> ActiveOrExpiredConfigsAsync(
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -8,6 +9,9 @@ using PnvPanel.Domain.Pricing;
namespace PnvPanel.Application.Billing.CreatePaymentRequest; namespace PnvPanel.Application.Billing.CreatePaymentRequest;
/// <summary>Проверка "нет активной заявки" + создание — под AdvisoryLock (по UserId): без неё
/// два одновременных запроса (двойной клик, повтор при таймауте) оба могли бы пройти проверку и
/// создать по заявке каждый — а дальше их обе можно независимо подтвердить, продлив оплату дважды.</summary>
public sealed class CreatePaymentRequestCommandHandler( public sealed class CreatePaymentRequestCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IIdentityService identityService, IIdentityService identityService,
@@ -32,21 +36,6 @@ public sealed class CreatePaymentRequestCommandHandler(
if (profile.MaxConfigs == RoleQuota.Unlimited) if (profile.MaxConfigs == RoleQuota.Unlimited)
return Result.Failure<PaymentRequestDto>(BillingErrors.UnlimitedRoleNotSupported); return Result.Failure<PaymentRequestDto>(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<PaymentRequestDto>(BillingErrors.ActiveRequestExists);
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
if (pricing is null) if (pricing is null)
return Result.Failure<PaymentRequestDto>(BillingErrors.PricingNotConfigured); return Result.Failure<PaymentRequestDto>(BillingErrors.PricingNotConfigured);
@@ -69,9 +58,35 @@ public sealed class CreatePaymentRequestCommandHandler(
var amount = PricingDiscount.Apply(rate * profile.MaxConfigs * command.Period.ToMonths(), discountPercent); var amount = PricingDiscount.Apply(rate * profile.MaxConfigs * command.Period.ToMonths(), discountPercent);
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<PaymentRequest>(BillingErrors.ActiveRequestExists);
var request = PaymentRequest.Create(userId, command.Period, amount); var request = PaymentRequest.Create(userId, command.Period, amount);
dbContext.PaymentRequests.Add(request); dbContext.PaymentRequests.Add(request);
return Result.Success(request);
},
cancellationToken
);
return Result.Success(PaymentRequestDto.FromDomain(request)); return claimed.IsSuccess
? Result.Success(PaymentRequestDto.FromDomain(claimed.Value))
: Result.Failure<PaymentRequestDto>(claimed.Error);
} }
} }
@@ -12,6 +12,7 @@ public sealed class MarkPaymentSentCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IIdentityService identityService, IIdentityService identityService,
IXuiPanelGateway gateway, IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ITelegramNotifier telegramNotifier,
ICurrentUser currentUser, ICurrentUser currentUser,
ILogger<MarkPaymentSentCommandHandler> logger ILogger<MarkPaymentSentCommandHandler> logger
@@ -48,6 +49,7 @@ public sealed class MarkPaymentSentCommandHandler(
await BillingConfigResumer.ProtectPendingConfigsAsync( await BillingConfigResumer.ProtectPendingConfigsAsync(
dbContext, dbContext,
gateway, gateway,
notifier,
logger, logger,
userId, userId,
cancellationToken cancellationToken
@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Concurrency;
/// <summary>
/// pg_advisory_xact_lock-обёртка для "проверил статус — потом изменил" операций (одобрение/отклонение
/// заявок и оплат), где два параллельных запроса по одному и тому же ключу (Id тикета/заявки) иначе
/// могут оба пройти проверку статуса и оба выполнить мутацию — например, одобрение одной и той же
/// заявки одновременно с сайта и из Telegram, оба независимо начисляющих дни/роль. Паттерн взят из
/// CreateVpnConfigCommandHandler.ReserveQuotaSlotAsync.
/// <para>
/// На нерелационном провайдере (EF Core InMemory в PnvPanel.Application.Tests — транзакции/raw SQL им
/// не поддерживаются) лок пропускается, <paramref name="action"/> выполняется напрямую: реальная
/// конкурентная гонка на InMemory всё равно невоспроизводима (тест однопоточный, изоляции транзакций
/// нет), а бизнес-логика внутри action всё равно должна быть покрыта тестами. Само по себе
/// сериализующее поведение лока (что второй параллельный вызов ждёт и видит уже закоммиченное
/// состояние) проверяется только PnvPanel.IntegrationTests на реальном Postgres.
/// </para>
/// <para>
/// Держите <paramref name="action"/> коротким — только проверка статуса и мутация сущности. Внешний
/// I/O (Telegram, гейтвей 3x-ui) должен идти ПОСЛЕ RunAsync, не внутри — иначе лок держит соединение к
/// БД открытым на время сетевого вызова.
/// </para>
/// </summary>
public static class AdvisoryLock
{
public static async Task<Result> RunAsync(
IAppDbContext dbContext,
Guid key,
Func<CancellationToken, Task<Result>> 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<Result<T>> RunAsync<T>(
IAppDbContext dbContext,
Guid key,
Func<CancellationToken, Task<Result<T>>> 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
);
}
@@ -36,7 +36,11 @@ public sealed record UserSummaryDto(
bool IsBlocked, bool IsBlocked,
DateTimeOffset? ActivatedAt, DateTimeOffset? ActivatedAt,
bool BillingEnabled, bool BillingEnabled,
DateTimeOffset? BillingPaidUntil DateTimeOffset? BillingPaidUntil,
/// <summary>Есть Subscription-заявка на оплату в AwaitingConfirmation — конфиги не гасятся, пока
/// админ не решит (см. BillingService). Заполняется в ListUsersQueryHandler (не здесь — Identity
/// не должен знать про PaymentRequests), false по умолчанию для мест, не подгружающих это поле.</summary>
bool BillingPendingReview = false
); );
public sealed record UserStatsDto(int Total, int Activated); public sealed record UserStatsDto(int Total, int Activated);
@@ -62,4 +62,9 @@ public interface IRealtimeNotifier
/// <summary>Новый комментарий или смена статуса — пушится автору тикета (не всем участникам треда).</summary> /// <summary>Новый комментарий или смена статуса — пушится автору тикета (не всем участникам треда).</summary>
Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken); Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken);
/// <summary>PaidUntil/BillingSuspended или защита на время проверки заявки изменились — фронт
/// (страница /billing) инвалидирует свой запрос статуса. Без конкретных данных в пейлоаде —
/// клиент сам перезапросит актуальное состояние (см. BillingConfigResumer).</summary>
Task NotifyBillingStatusChangedAsync(Guid userId, CancellationToken cancellationToken);
} }
@@ -35,4 +35,9 @@ public static class ConfigErrors
"Configs.BillingRequired", "Configs.BillingRequired",
"Требуется оплата подписки — оформите заявку на оплату в разделе «Оплата»." "Требуется оплата подписки — оформите заявку на оплату в разделе «Оплата»."
); );
public static readonly Error RotateFailed = Error.Failure(
"Configs.RotateFailed",
"Не удалось сохранить перевыпущенный конфиг. Попробуйте повторить операцию позже."
);
} }
@@ -67,7 +67,25 @@ public sealed class RotateVpnConfigCommandHandler(
var oldClientExternalId = config.ClientExternalId; var oldClientExternalId = config.ClientExternalId;
config.Rotate(newClientEmail, addResult.Value); config.Rotate(newClientEmail, addResult.Value);
try
{
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
// Новый клиент уже создан в панели, но локально сохранить не вышло (сбой БД) — без
// компенсации это осиротевший рабочий клиент, никак не привязанный ни к одному конфигу:
// откатываем его создание, чтобы не оставлять "чужой" доступ висеть в панели.
await gateway.RemoveClientAsync(
node,
inbound.RemoteInboundId,
addResult.Value,
config.Protocol,
cancellationToken
);
return Result.Failure<VpnConfigDto>(ConfigErrors.RotateFailed);
}
// Старого клиента удаляем ПОСЛЕ коммита нового состояния: если удаление не выйдет, // Старого клиента удаляем ПОСЛЕ коммита нового состояния: если удаление не выйдет,
// у пользователя просто останется лишний нерабочий-для-него клиент в панели — не критично. // у пользователя просто останется лишний нерабочий-для-него клиент в панели — не критично.
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Billing; using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -8,6 +9,8 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.CreateExtensionRequest; namespace PnvPanel.Application.Support.CreateExtensionRequest;
/// <summary>Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см.
/// CreatePaymentRequestCommandHandler для полного обоснования.</summary>
public sealed class CreateExtensionRequestTicketCommandHandler( public sealed class CreateExtensionRequestTicketCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IIdentityService identityService, IIdentityService identityService,
@@ -31,22 +34,35 @@ public sealed class CreateExtensionRequestTicketCommandHandler(
if (!profile.BillingEnabled) if (!profile.BillingEnabled)
return Result.Failure<TicketDetailDto>(BillingErrors.NotEnabled); return Result.Failure<TicketDetailDto>(BillingErrors.NotEnabled);
var claimed = await AdvisoryLock.RunAsync(
dbContext,
userId,
async lockedCancellationToken =>
{
var hasPending = await dbContext.SupportTickets.AnyAsync( var hasPending = await dbContext.SupportTickets.AnyAsync(
t => t =>
t.UserId == userId t.UserId == userId
&& t.Type == TicketType.ExtensionRequest && t.Type == TicketType.ExtensionRequest
&& t.Status == TicketStatus.Open, && t.Status == TicketStatus.Open,
cancellationToken lockedCancellationToken
); );
if (hasPending) if (hasPending)
return Result.Failure<TicketDetailDto>(SupportErrors.ExtensionRequestAlreadyPending); return Result.Failure<(SupportTicket, TicketComment)>(
SupportErrors.ExtensionRequestAlreadyPending
);
var ticket = SupportTicket.CreateExtensionRequest(userId, command.RequestedDays); var newTicket = SupportTicket.CreateExtensionRequest(userId, command.RequestedDays);
dbContext.SupportTickets.Add(ticket); dbContext.SupportTickets.Add(newTicket);
var newComment = TicketComment.Create(newTicket.Id, userId, command.Justification);
var comment = TicketComment.Create(ticket.Id, userId, command.Justification); dbContext.TicketComments.Add(newComment);
dbContext.TicketComments.Add(comment); return Result.Success((newTicket, newComment));
},
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure<TicketDetailDto>(claimed.Error);
var (ticket, comment) = claimed.Value;
var userName = currentUser.UserName ?? userId.ToString(); var userName = currentUser.UserName ?? userId.ToString();
await notifier.NotifyTicketCreatedAsync( await notifier.NotifyTicketCreatedAsync(
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -7,6 +8,8 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.CreateRoleRequest; namespace PnvPanel.Application.Support.CreateRoleRequest;
/// <summary>Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см.
/// CreatePaymentRequestCommandHandler для полного обоснования.</summary>
public sealed class CreateRoleRequestTicketCommandHandler( public sealed class CreateRoleRequestTicketCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IRoleService roleService, IRoleService roleService,
@@ -27,47 +30,57 @@ public sealed class CreateRoleRequestTicketCommandHandler(
if (currentUser.UserId is not { } userId) if (currentUser.UserId is not { } userId)
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized); return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
var hasPending = await dbContext.SupportTickets.AnyAsync(
t =>
t.UserId == userId
&& t.Type == TicketType.RoleRequest
&& t.Status == TicketStatus.Open,
cancellationToken
);
if (hasPending)
return Result.Failure<TicketDetailDto>(SupportErrors.RoleRequestAlreadyPending);
SupportTicket ticket;
string? requestedRoleName = null; string? requestedRoleName = null;
if (command.ExistingRoleId is { } existingRoleId)
if (command.ExistingRoleId is { } roleId)
{ {
var roles = await roleService.ListRolesAsync(cancellationToken); 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) if (role is null)
return Result.Failure<TicketDetailDto>(SupportErrors.RoleNotFound); return Result.Failure<TicketDetailDto>(SupportErrors.RoleNotFound);
if (role.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase)) if (role.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
return Result.Failure<TicketDetailDto>(SupportErrors.CannotRequestAdminRole); return Result.Failure<TicketDetailDto>(SupportErrors.CannotRequestAdminRole);
ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
requestedRoleName = role.Name; requestedRoleName = role.Name;
} }
else
var claimed = await AdvisoryLock.RunAsync(
dbContext,
userId,
async lockedCancellationToken =>
{ {
ticket = SupportTicket.CreateRoleRequestForNewRole( var hasPending = await dbContext.SupportTickets.AnyAsync(
t =>
t.UserId == userId
&& t.Type == TicketType.RoleRequest
&& t.Status == TicketStatus.Open,
lockedCancellationToken
);
if (hasPending)
return Result.Failure<(SupportTicket, TicketComment)>(
SupportErrors.RoleRequestAlreadyPending
);
var newTicket =
command.ExistingRoleId is { } roleId
? SupportTicket.CreateRoleRequestForExistingRole(userId, roleId)
: SupportTicket.CreateRoleRequestForNewRole(
userId, userId,
command.NewRoleName!, command.NewRoleName!,
command.NewRoleMaxConfigs!.Value, command.NewRoleMaxConfigs!.Value,
command.NewRoleMaxIpLimit!.Value command.NewRoleMaxIpLimit!.Value
); );
} dbContext.SupportTickets.Add(newTicket);
var newComment = TicketComment.Create(newTicket.Id, userId, command.Justification);
dbContext.SupportTickets.Add(ticket); dbContext.TicketComments.Add(newComment);
return Result.Success((newTicket, newComment));
var comment = TicketComment.Create(ticket.Id, userId, command.Justification); },
dbContext.TicketComments.Add(comment); cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure<TicketDetailDto>(claimed.Error);
var (ticket, comment) = claimed.Value;
var userName = currentUser.UserName ?? userId.ToString(); var userName = currentUser.UserName ?? userId.ToString();
var roleDescription = var roleDescription =
requestedRoleName requestedRoleName
@@ -28,6 +28,9 @@ public sealed class ReopenTicketCommandHandler(
if (ticket is null) if (ticket is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure(SupportErrors.NotFound);
if (ticket.Type != TicketType.BugReport)
return Result.Failure(SupportErrors.OnlyBugReportCanBeReopened);
if (ticket.Status != TicketStatus.Resolved) if (ticket.Status != TicketStatus.Resolved)
return Result.Failure(SupportErrors.NotResolved); return Result.Failure(SupportErrors.NotResolved);
@@ -38,6 +38,26 @@ public static class SupportErrors
"Переоткрыть можно только решённый тикет." "Переоткрыть можно только решённый тикет."
); );
/// <summary>Заявка на роль/продление решается только через approve/reject — там же выполняется
/// сама выдача роли/дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего
/// не выдав пользователю.</summary>
public static readonly Error OnlyBugReportCanBeResolvedDirectly = Error.Validation(
"Support.OnlyBugReportCanBeResolvedDirectly",
"Заявку на роль или продление можно только одобрить/отклонить, а не решить напрямую."
);
public static readonly Error OnlyBugReportCanBeClosedDirectly = Error.Validation(
"Support.OnlyBugReportCanBeClosedDirectly",
"Заявку на роль или продление можно только одобрить/отклонить, а не закрыть напрямую."
);
/// <summary>Заявка на роль/продление одноразовая: повторное одобрение начислило бы дни/роль ещё
/// раз. Новый запрос — новый тикет, не переоткрытие старого.</summary>
public static readonly Error OnlyBugReportCanBeReopened = Error.Validation(
"Support.OnlyBugReportCanBeReopened",
"Заявку на роль или продление нельзя переоткрыть — оформите новую."
);
public static readonly Error RoleRequestAlreadyPending = Error.Conflict( public static readonly Error RoleRequestAlreadyPending = Error.Conflict(
"Support.RoleRequestAlreadyPending", "Support.RoleRequestAlreadyPending",
"У вас уже есть необработанная заявка на роль." "У вас уже есть необработанная заявка на роль."
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
@@ -11,6 +12,9 @@ namespace PnvPanel.Application.Telegram;
/// Формально Query, но при первом наблюдении Approved-статуса атомарно "забирает" вход: /// Формально Query, но при первом наблюдении Approved-статуса атомарно "забирает" вход:
/// выпускает JWT и переводит запрос в Consumed (одноразовый claim), см. api-design.md. /// выпускает JWT и переводит запрос в Consumed (одноразовый claim), см. api-design.md.
/// Осознанное отступление от чистого CQRS ради простого поллинга без отдельного claim-эндпоинта. /// Осознанное отступление от чистого CQRS ради простого поллинга без отдельного claim-эндпоинта.
/// Claim — под AdvisoryLock (по Id запроса): без неё два одновременных поллинга (два открытых окна)
/// оба могли бы увидеть Approved до того, как первый допишет Consume(), и оба выпустить валидную пару
/// токенов из одного подтверждения.
/// </summary> /// </summary>
public sealed class GetLoginRequestStatusQueryHandler( public sealed class GetLoginRequestStatusQueryHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
@@ -37,18 +41,48 @@ public sealed class GetLoginRequestStatusQueryHandler(
if (request.Status != TelegramLoginStatus.Approved) if (request.Status != TelegramLoginStatus.Approved)
return Result.Success(new LoginRequestStatusDto(request.Status, null)); return Result.Success(new LoginRequestStatusDto(request.Status, null));
var profile = await identityService.GetProfileAsync( var claimed = await AdvisoryLock.RunAsync(
request.UserId!.Value, 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<Guid>(TelegramErrors.LoginRequestAlreadyClaimed);
fresh.Consume();
return Result.Success(fresh.UserId!.Value);
},
cancellationToken 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) if (profile is null)
return Result.Failure<LoginRequestStatusDto>(AuthErrors.Unauthorized); return Result.Failure<LoginRequestStatusDto>(AuthErrors.Unauthorized);
// Обычный логин отказывает заблокированному сразу — passwordless-вход через Telegram обязан
// делать то же самое, иначе блокировка полностью обходится этим путём.
if (profile.IsBlocked)
return Result.Failure<LoginRequestStatusDto>(AuthErrors.UserBlocked);
var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role); var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser); var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser);
request.Consume();
// IssueAsync сохраняет весь SaveChanges (в т.ч. Consume() выше) — см. RefreshTokenService.
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken); var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
// Пользователь только что подтвердил вход через бота — Telegram точно привязан. // Пользователь только что подтвердил вход через бота — Telegram точно привязан.
@@ -14,6 +14,11 @@ public static class TelegramErrors
"Запрос на вход не найден." "Запрос на вход не найден."
); );
public static readonly Error LoginRequestAlreadyClaimed = Error.Conflict(
"Telegram.LoginRequestAlreadyClaimed",
"Запрос на вход уже обработан."
);
public static readonly Error NotLinked = Error.Conflict( public static readonly Error NotLinked = Error.Conflict(
"Telegram.NotLinked", "Telegram.NotLinked",
"Telegram не привязан ни к одному аккаунту." "Telegram не привязан ни к одному аккаунту."
@@ -51,17 +51,27 @@ public sealed class Inbound : Entity
Remark = remark; Remark = remark;
Port = port; Port = port;
LastSyncAt = DateTimeOffset.UtcNow; LastSyncAt = DateTimeOffset.UtcNow;
// Инбаунд снова пришёл в синхронизации — значит панель его видит, снимаем MarkUnavailable()
// (иначе после ЛЮБОГО временного сбоя синка — не только реального удаления — Revoke навсегда
// пропускал бы обращение к панели для конфигов на этом инбаунде, хотя клиент в панели жив).
IsAvailable = true;
} }
public void Publish(string? displayName, IReadOnlyCollection<Guid> allowedRoleIds) public void Publish(string? displayName, IReadOnlyCollection<Guid> allowedRoleIds)
{ {
IsPublished = true; IsPublished = true;
IsAvailable = true;
DisplayName = displayName; DisplayName = displayName;
AllowedRoleIds = allowedRoleIds.Distinct().ToList(); AllowedRoleIds = allowedRoleIds.Distinct().ToList();
} }
public void Unpublish() => IsPublished = false; public void Unpublish() => IsPublished = false;
/// <summary>Роль удалена админом — снимаем её из списка разрешённых, чтобы не оставлять ссылку на
/// несуществующую роль (иначе она навсегда "съедала" бы место в списке, ни на что не влияя, но и
/// не позволяя понять, что инбаунд стал доступен меньшему числу ролей, чем задумано).</summary>
public void RemoveAllowedRole(Guid roleId) => AllowedRoleIds = AllowedRoleIds.Where(id => id != roleId).ToList();
/// <summary> /// <summary>
/// Инбаунд пропал на панели (не пришёл в очередной синхронизации), но по нему есть конфиги — /// Инбаунд пропал на панели (не пришёл в очередной синхронизации), но по нему есть конфиги —
/// удалить запись нельзя (FK), поэтому помечаем недоступной вместо тихого "не опубликован": /// удалить запись нельзя (FK), поэтому помечаем недоступной вместо тихого "не опубликован":
@@ -75,6 +75,7 @@ public sealed class BillingService(
await BillingConfigResumer.ProtectPendingConfigsAsync( await BillingConfigResumer.ProtectPendingConfigsAsync(
dbContext, dbContext,
gateway, gateway,
notifier,
logger, logger,
user.UserId, user.UserId,
cancellationToken cancellationToken
@@ -118,6 +118,17 @@ internal sealed class RoleService(
if (usersInRole.Count > 0) if (usersInRole.Count > 0)
return Result.Failure(RoleErrors.RoleInUse); 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); await roleManager.DeleteAsync(role);
return Result.Success(); return Result.Success();
} }
@@ -157,6 +157,23 @@ internal sealed class XuiPanelGateway(
CancellationToken cancellationToken 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 try
{ {
var client = GetClient(node); var client = GetClient(node);
@@ -220,6 +220,7 @@ public class ConfirmPaymentRequestCommandHandlerTests
Arg.Is<DateTimeOffset?>(d => d == activeConfig.ExpiresAt), Arg.Is<DateTimeOffset?>(d => d == activeConfig.ExpiresAt),
Arg.Any<CancellationToken>() Arg.Any<CancellationToken>()
); );
await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -147,6 +147,8 @@ public class RejectPaymentRequestCommandHandlerTests
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(),
Arg.Any<CancellationToken>() Arg.Any<CancellationToken>()
); );
// Фронт (/billing) должен узнать о смене статуса сразу, не дожидаясь опроса.
await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -1,6 +1,7 @@
using NSubstitute; using NSubstitute;
using PnvPanel.Application.Admin.Support; using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Support;
using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Support; using PnvPanel.Domain.Support;
using Xunit; using Xunit;
@@ -65,4 +66,31 @@ public class CloseTicketCommandHandlerTests
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
Assert.Equal(TicketStatus.Closed, ticket.Status); 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);
}
} }
@@ -1,6 +1,7 @@
using NSubstitute; using NSubstitute;
using PnvPanel.Application.Admin.Support; using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Support;
using PnvPanel.Application.Tests.TestSupport; using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Support; using PnvPanel.Domain.Support;
using Xunit; using Xunit;
@@ -65,4 +66,34 @@ public class ResolveTicketCommandHandlerTests
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
Assert.Equal(TicketStatus.Resolved, ticket.Status); 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);
}
} }
@@ -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<IIdentityService>();
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<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([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<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([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<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([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);
}
}
@@ -100,4 +100,41 @@ public class RefreshCommandHandlerTests
Assert.False(result.IsSuccess); Assert.False(result.IsSuccess);
Assert.Equal(AuthErrors.InvalidRefreshToken, result.Error); 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<CancellationToken>())
.Returns(Result.Success(rotated));
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).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<AuthenticatedUser>());
}
} }
@@ -150,6 +150,9 @@ public class CreatePaymentRequestCommandHandlerTests
{ {
using var dbContext = InMemoryDbContextFactory.Create(); using var dbContext = InMemoryDbContextFactory.Create();
var userId = Guid.NewGuid(); 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)); dbContext.PaymentRequests.Add(PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000));
await dbContext.SaveChangesAsync(CancellationToken.None); await dbContext.SaveChangesAsync(CancellationToken.None);
@@ -16,13 +16,14 @@ public class MarkPaymentSentCommandHandlerTests
{ {
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>(); private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>(); private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>(); private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
private readonly ILogger<MarkPaymentSentCommandHandler> _logger = Substitute.For< private readonly ILogger<MarkPaymentSentCommandHandler> _logger = Substitute.For<
ILogger<MarkPaymentSentCommandHandler> ILogger<MarkPaymentSentCommandHandler>
>(); >();
private MarkPaymentSentCommandHandler CreateHandler(IAppDbContext dbContext, Guid userId) => 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) => private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) =>
new( new(
@@ -139,6 +140,7 @@ public class MarkPaymentSentCommandHandlerTests
Arg.Is<DateTimeOffset?>(d => d > DateTimeOffset.UtcNow), Arg.Is<DateTimeOffset?>(d => d > DateTimeOffset.UtcNow),
Arg.Any<CancellationToken>() Arg.Any<CancellationToken>()
); );
await _notifier.Received(1).NotifyBillingStatusChangedAsync(userId, Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -1,3 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using NSubstitute; using NSubstitute;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
@@ -5,13 +7,53 @@ using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Configs; using PnvPanel.Application.Configs;
using PnvPanel.Application.Configs.Rotate; using PnvPanel.Application.Configs.Rotate;
using PnvPanel.Application.Tests.TestSupport; 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.Configs;
using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions;
using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Pricing;
using PnvPanel.Domain.Support;
using PnvPanel.Domain.Telegram;
using PnvPanel.Infrastructure.Persistence;
using Xunit; using Xunit;
namespace PnvPanel.Application.Tests.Configs.Rotate; namespace PnvPanel.Application.Tests.Configs.Rotate;
/// <summary>Оборачивает реальный AppDbContext, но всегда фейлит SaveChangesAsync — симулирует сбой БД
/// после успешного создания клиента в панели (DbUpdateException — единственный тип, который ловит
/// RotateVpnConfigCommandHandler для компенсирующего отката).</summary>
public sealed class ThrowingSaveDbContext(AppDbContext inner) : IAppDbContext
{
public DbSet<ActivationRequest> ActivationRequests => inner.ActivationRequests;
public DbSet<AuditLog> AuditLogs => inner.AuditLogs;
public DbSet<TelegramLinkToken> TelegramLinkTokens => inner.TelegramLinkTokens;
public DbSet<TelegramLoginRequest> TelegramLoginRequests => inner.TelegramLoginRequests;
public DbSet<Node> Nodes => inner.Nodes;
public DbSet<Inbound> Inbounds => inner.Inbounds;
public DbSet<VpnConfig> VpnConfigs => inner.VpnConfigs;
public DbSet<TrafficSample> TrafficSamples => inner.TrafficSamples;
public DbSet<ClientApp> ClientApps => inner.ClientApps;
public DbSet<NewsPost> NewsPosts => inner.NewsPosts;
public DbSet<SupportTicket> SupportTickets => inner.SupportTickets;
public DbSet<TicketComment> TicketComments => inner.TicketComments;
public DbSet<TicketAttachment> TicketAttachments => inner.TicketAttachments;
public DbSet<InstructionIntro> InstructionIntros => inner.InstructionIntros;
public DbSet<InstructionTab> InstructionTabs => inner.InstructionTabs;
public DbSet<PricingSettings> PricingSettings => inner.PricingSettings;
public DbSet<PricingDiscountTier> PricingDiscountTiers => inner.PricingDiscountTiers;
public DbSet<BillingSettings> BillingSettings => inner.BillingSettings;
public DbSet<PaymentRequest> PaymentRequests => inner.PaymentRequests;
public DatabaseFacade Database => inner.Database;
public Task<int> SaveChangesAsync(CancellationToken cancellationToken) =>
throw new DbUpdateException("simulated failure");
}
public class RotateVpnConfigCommandHandlerTests public class RotateVpnConfigCommandHandlerTests
{ {
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>(); private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
@@ -231,4 +273,76 @@ public class RotateVpnConfigCommandHandlerTests
Assert.Equal(gatewayError, result.Error); Assert.Equal(gatewayError, result.Error);
Assert.Equal("old-external-id", config.ClientExternalId); 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<CancellationToken>())
.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<Node>(),
inbound.RemoteInboundId,
config.Protocol,
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<DateTimeOffset?>(),
Arg.Any<CancellationToken>()
)
.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<Node>(),
inbound.RemoteInboundId,
"new-external-id",
config.Protocol,
Arg.Any<CancellationToken>()
);
await _gateway
.DidNotReceive()
.RemoveClientAsync(
Arg.Any<Node>(),
inbound.RemoteInboundId,
"old-external-id",
config.Protocol,
Arg.Any<CancellationToken>()
);
}
} }
@@ -83,4 +83,29 @@ public class ReopenTicketCommandHandlerTests
Assert.False(result.IsSuccess); Assert.False(result.IsSuccess);
Assert.Equal(SupportErrors.NotFound, result.Error); 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);
}
} }
@@ -150,4 +150,74 @@ public class GetLoginRequestStatusQueryHandlerTests
Assert.False(result.IsSuccess); Assert.False(result.IsSuccess);
Assert.Equal(AuthErrors.Unauthorized, result.Error); 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<CancellationToken>()).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<Guid>(), Arg.Any<CancellationToken>());
}
[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<Guid>(), Arg.Any<CancellationToken>());
}
} }
@@ -80,4 +80,42 @@ public class InboundTests
Assert.False(inbound.IsAvailable); Assert.False(inbound.IsAvailable);
Assert.False(inbound.IsPublished); 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);
}
} }
+5
View File
@@ -362,6 +362,11 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи
| `newsPublished` | `{ id, title, createdAt }` | все (broadcast) | | `newsPublished` | `{ id, title, createdAt }` | все (broadcast) |
| `ticketCreated` | `{ ticketId, userId, userName, type }` | `admins` | | `ticketCreated` | `{ ticketId, userId, userName, type }` | `admins` |
| `ticketUpdated` | `{ ticketId }` | владельцу | | `ticketUpdated` | `{ ticketId }` | владельцу |
| `billingStatusChanged` | `{ userId }` | владельцу |
`billingStatusChanged` — безадресный пинг без данных (см. `BillingConfigResumer` в
[domain-model.md](domain-model.md#синхронизация-панели-3x-ui-со-статусом-оплаты--billingconfigresumer)):
клиент в ответ инвалидирует `my-billing-status`, а не читает payload.
### Client → Server ### Client → Server
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
+57 -2
View File
@@ -83,7 +83,10 @@ AppUser
`RemoteInboundId` — 3x-ui не переиспользует id — накапливался бы визуальный дубль). Если конфиги `RemoteInboundId` — 3x-ui не переиспользует id — накапливался бы визуальный дубль). Если конфиги
есть — удалить нельзя (FK), инбаунд помечается `MarkUnavailable()` (`IsAvailable=false`, есть — удалить нельзя (FK), инбаунд помечается `MarkUnavailable()` (`IsAvailable=false`,
`IsPublished=false`): новые конфиги на нём не создать, а `Revoke` для существующих конфигов не бьёт `IsPublished=false`): новые конфиги на нём не создать, а `Revoke` для существующих конфигов не бьёт
в панель повторно (см. `RevokeVpnConfigCommandHandler`), а отзывает локально. в панель повторно (см. `RevokeVpnConfigCommandHandler`), а отзывает локально. Если инбаунд позже
снова появляется в ответе 3x-ui (`UpdateFromRemote`) или переопубликовывается (`Publish`) —
`IsAvailable` сбрасывается обратно в `true`: без этого разово пропавший инбаунд оставался бы
недоступным навсегда, даже вернувшись на панель.
> `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический > `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический
> индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать > индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать
@@ -119,10 +122,22 @@ AppUser
`IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после `IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после
успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация). успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация).
- **Проверка квоты выполняется под `pg_advisory_xact_lock(hashtext(userId))`** в транзакции создания - **Проверка квоты выполняется под `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. - `Revoke()` → статус `Revoked` (запись остаётся для истории/аудита); хендлер отдельно удаляет клиента в 3x-ui.
- `Rotate(newClientEmail, newClientExternalId)` → перевыпуск: хендлер создаёт нового клиента в 3x-ui, - `Rotate(newClientEmail, newClientExternalId)` → перевыпуск: хендлер создаёт нового клиента в 3x-ui,
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки. удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
Если после успешного создания нового клиента в панели `SaveChangesAsync` падает (сбой БД) —
хендлер откатывает созданного клиента через `RemoveClientAsync` и возвращает `RotateFailed`, не
оставляя осиротевшего рабочего клиента, ни к одному конфигу не привязанного.
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение - `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера). самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).
- `Suspend()`/`Resume()` → меняют статус (`Active ↔ Expired`), отдельно от `Disable()`/`Enable()` - `Suspend()`/`Resume()` → меняют статус (`Active ↔ Expired`), отдельно от `Disable()`/`Enable()`
@@ -135,6 +150,14 @@ AppUser
трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp` трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp`
всегда `null` — «не менять»). всегда `null` — «не менять»).
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения. - `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`): создание, - **Доступ разрешён только активированному пользователю** (`AppUser.IsActivated == true`): создание,
просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также
чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline
@@ -314,6 +337,12 @@ UI **настойчиво напоминает** привязать его (ед
одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не
плодить тикеты, которые некому обработать, если админ единственный. плодить тикеты, которые некому обработать, если админ единственный.
**Удаление роли** (`IRoleService.DeleteRoleAsync`): запрещено для системных ролей и пока есть живые
пользователи с этой ролью (`RoleErrors.RoleInUse`). Живых пользователей нет — но `Inbound.AllowedRoleIds`
(plain `uuid[]`, без FK) мог всё ещё указывать удаляемую роль; перед `RoleManager.DeleteAsync`
хендлер подчищает такие ссылки (`Inbound.RemoveAllowedRole`), иначе "мёртвый" Id молча оставался бы
висеть в массиве — не пуская никого нового, но и не давая понять почему.
### PricingSettings — глобальная справочная цена конфига ### PricingSettings — глобальная справочная цена конфига
Единственная строка в таблице (singleton) — цена за один конфиг, редактируется админом. Не привязана Единственная строка в таблице (singleton) — цена за один конфиг, редактируется админом. Не привязана
к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с
@@ -538,6 +567,16 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
всё ещё лежит старое `AwaitingConfirmation` — без исключения по Id проверка ложно приняла бы её за ещё всё ещё лежит старое `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`: Прочие точки, не входящие в `BillingConfigResumer`:
- **Создание** (`CreateVpnConfigCommandHandler`) пушит `expiresAt = profile.BillingPaidUntil` при - **Создание** (`CreateVpnConfigCommandHandler`) пушит `expiresAt = profile.BillingPaidUntil` при
`BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок. `BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок.
@@ -605,6 +644,14 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
Переходы: `Pending → Approved/Rejected/Expired`; `Approved → Consumed` (после выпуска JWT сайту). Переходы: `Pending → Approved/Rejected/Expired`; `Approved → Consumed` (после выпуска JWT сайту).
После `Consumed`/`Expired` — не переиспользуется. После `Consumed`/`Expired` — не переиспользуется.
`GetLoginRequestStatusQueryHandler` (поллинг статуса с сайта) при первом наблюдении `Approved`
атомарно "забирает" вход: под `AdvisoryLock` (по Id запроса) заново проверяет статус, `Consume()`-ит
и только потом выпускает JWT — без лока два одновременных поллинга (два открытых окна той же вкладки)
могли бы оба увидеть `Approved` до того, как первый допишет `Consume()`, и оба выпустить валидную пару
токенов из одного подтверждения. Как и обычный логин — отказывает **заблокированному** пользователю
(`AppUser.IsBlocked`) до выпуска токенов; так же поступает `RefreshCommandHandler` при ротации
refresh-токена — иначе блокировка обходилась бы passwordless-входом/уже выданным refresh-токеном.
### SupportTicket — обращение в поддержку ### SupportTicket — обращение в поддержку
Три вида: `BugReport` (свободная форма, с вложениями), `RoleRequest` (запрос существующей роли — Три вида: `BugReport` (свободная форма, с вложениями), `RoleRequest` (запрос существующей роли —
кроме `admin` — либо параметров новой) и `ExtensionRequest` (продление оплаченного периода на N кроме `admin` — либо параметров новой) и `ExtensionRequest` (продление оплаченного периода на N
@@ -638,6 +685,14 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest`/ - `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest`/
`ExtensionRequest` — отклонение. `ExtensionRequest` — отклонение.
- `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается. - `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 == RoleRequest && Status == Open`)
и отдельно не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`) и отдельно не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`)
на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`. на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.
@@ -155,7 +155,7 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
{user.billingEnabled && ( {user.billingEnabled && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>{t('admin.users.billingLabel')}</Label> <Label>{t('admin.users.billingLabel')}</Label>
<PaidUntilBadge paidUntil={user.billingPaidUntil} showDate /> <PaidUntilBadge paidUntil={user.billingPaidUntil} pendingReview={user.billingPendingReview} showDate />
<div className="mt-1 flex gap-2"> <div className="mt-1 flex gap-2">
<Input <Input
type="number" type="number"
@@ -100,7 +100,7 @@ export function TicketDetailDialog({ ticketId, onOpenChange }: { ticketId: strin
))} ))}
</div> </div>
{data.status === 'Resolved' && ( {data.status === 'Resolved' && data.type === 'BugReport' && (
<Button variant="outline" size="sm" disabled={reopenMutation.isPending} onClick={() => reopenMutation.mutate()}> <Button variant="outline" size="sm" disabled={reopenMutation.isPending} onClick={() => reopenMutation.mutate()}>
{t('support.reopen')} {t('support.reopen')}
</Button> </Button>
+15 -5
View File
@@ -8,7 +8,6 @@ import { Badge } from '@/shared/ui/badge'
import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge' import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge'
import { listUsers } from '@/features/admin/users/api' import { listUsers } from '@/features/admin/users/api'
import { UserManageDialog } from '@/features/admin/users/UserManageDialog' import { UserManageDialog } from '@/features/admin/users/UserManageDialog'
import type { UserSummaryDto } from '@/shared/api/types'
export const Route = createFileRoute('/admin/users')({ component: AdminUsersPage }) export const Route = createFileRoute('/admin/users')({ component: AdminUsersPage })
@@ -18,12 +17,17 @@ function AdminUsersPage() {
const { t } = useTranslation() const { t } = useTranslation()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [managing, setManaging] = useState<UserSummaryDto | null>(null) // Id, не сам объект — иначе диалог держит "замороженный" снимок пользователя и не видит
// изменения, сделанные им же самим (гифт/блок/смена роли инвалидируют этот запрос, но проп
// диалога от этого не обновится, если хранить готовый объект). Ищем свежую версию в live-данных
// запроса на каждый рендер.
const [managingId, setManagingId] = useState<string | null>(null)
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-users', page, search], queryKey: ['admin-users', page, search],
queryFn: () => listUsers(page, PAGE_SIZE, search || undefined), queryFn: () => listUsers(page, PAGE_SIZE, search || undefined),
}) })
const managingUser = managingId ? (data?.items.find((u) => u.id === managingId) ?? null) : null
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -77,13 +81,17 @@ function AdminUsersPage() {
</td> </td>
<td className="py-2"> <td className="py-2">
{user.billingEnabled ? ( {user.billingEnabled ? (
<PaidUntilBadge paidUntil={user.billingPaidUntil} showDate /> <PaidUntilBadge
paidUntil={user.billingPaidUntil}
pendingReview={user.billingPendingReview}
showDate
/>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
)} )}
</td> </td>
<td className="py-2 text-right"> <td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setManaging(user)}> <Button size="sm" variant="outline" onClick={() => setManagingId(user.id)}>
{t('admin.users.manage')} {t('admin.users.manage')}
</Button> </Button>
</td> </td>
@@ -109,7 +117,9 @@ function AdminUsersPage() {
</> </>
)} )}
{managing && <UserManageDialog user={managing} open={!!managing} onOpenChange={(open) => !open && setManaging(null)} />} {managingUser && (
<UserManageDialog user={managingUser} open={!!managingUser} onOpenChange={(open) => !open && setManagingId(null)} />
)}
</div> </div>
) )
} }
+3
View File
@@ -167,6 +167,9 @@ export type UserSummaryDto = {
activatedAt: string | null activatedAt: string | null
billingEnabled: boolean billingEnabled: boolean
billingPaidUntil: string | null billingPaidUntil: string | null
/** Есть Subscription-заявка на оплату, ожидающая решения админа — конфиги не гасятся, пока он не
* решит (см. BillingService). Бейдж должен показывать нейтральный статус, а не тревожный "Истекло". */
billingPendingReview: boolean
} }
export type RoleDto = { export type RoleDto = {
+6
View File
@@ -132,6 +132,9 @@ const resources = {
requestStatus: { requestStatus: {
AwaitingPayment: 'Ожидает оплаты', AwaitingPayment: 'Ожидает оплаты',
AwaitingConfirmation: 'На проверке у администратора', AwaitingConfirmation: 'На проверке у администратора',
Confirmed: 'Подтверждена',
Rejected: 'Отклонена',
Cancelled: 'Отменена',
}, },
requisites: 'Реквизиты для оплаты', requisites: 'Реквизиты для оплаты',
requisitesMissing: 'Реквизиты ещё не настроены администратором.', requisitesMissing: 'Реквизиты ещё не настроены администратором.',
@@ -674,6 +677,9 @@ const resources = {
requestStatus: { requestStatus: {
AwaitingPayment: 'Awaiting payment', AwaitingPayment: 'Awaiting payment',
AwaitingConfirmation: 'Under admin review', AwaitingConfirmation: 'Under admin review',
Confirmed: 'Confirmed',
Rejected: 'Rejected',
Cancelled: 'Cancelled',
}, },
requisites: 'Payment details', requisites: 'Payment details',
requisitesMissing: 'The administrator has not set up payment details yet.', requisitesMissing: 'The administrator has not set up payment details yet.',
@@ -8,6 +8,7 @@ type ConfigTrafficUpdated = { configId: string; usedUpBytes: number; usedDownByt
type ConfigStatusChanged = { configId: string; status: ConfigStatus } type ConfigStatusChanged = { configId: string; status: ConfigStatus }
type UserActivated = { userId: string } type UserActivated = { userId: string }
type NewsPublished = { id: string; title: string; createdAt: string } type NewsPublished = { id: string; title: string; createdAt: string }
type BillingStatusChanged = { userId: string }
/** Живые обновления по SignalR: точечно патчит кэш TanStack Query вместо инвалидации всего списка. */ /** Живые обновления по SignalR: точечно патчит кэш TanStack Query вместо инвалидации всего списка. */
export function RealtimeProvider({ children }: { children: React.ReactNode }) { export function RealtimeProvider({ children }: { children: React.ReactNode }) {
@@ -54,10 +55,15 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
void queryClient.invalidateQueries({ queryKey: ['news'] }) void queryClient.invalidateQueries({ queryKey: ['news'] })
} }
const onBillingStatusChanged = (_payload: BillingStatusChanged) => {
void queryClient.invalidateQueries({ queryKey: ['my-billing-status'] })
}
connection.on('configTrafficUpdated', onTrafficUpdated) connection.on('configTrafficUpdated', onTrafficUpdated)
connection.on('configStatusChanged', onStatusChanged) connection.on('configStatusChanged', onStatusChanged)
connection.on('userActivated', onUserActivated) connection.on('userActivated', onUserActivated)
connection.on('newsPublished', onNewsPublished) connection.on('newsPublished', onNewsPublished)
connection.on('billingStatusChanged', onBillingStatusChanged)
void startConnection() void startConnection()
@@ -66,6 +72,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
connection.off('configStatusChanged', onStatusChanged) connection.off('configStatusChanged', onStatusChanged)
connection.off('userActivated', onUserActivated) connection.off('userActivated', onUserActivated)
connection.off('newsPublished', onNewsPublished) connection.off('newsPublished', onNewsPublished)
connection.off('billingStatusChanged', onBillingStatusChanged)
} }
}, [user, queryClient]) }, [user, queryClient])