Implement billing status notification and enhance user management integration
- Added `NotifyBillingStatusChangedAsync` method to `IRealtimeNotifier` for notifying clients about changes in billing status. - Updated `BillingConfigResumer` to call the new notification method after modifying billing configurations, ensuring users receive real-time updates. - Enhanced `ListUsersQueryHandler` to include a `BillingPendingReview` property in `UserSummaryDto`, indicating if a user has a pending payment request awaiting confirmation. - Refactored various command handlers to utilize `AdvisoryLock` for managing concurrent requests, preventing race conditions in billing operations. - Updated tests to cover new notification behaviors and ensure proper functionality in billing status management.
This commit is contained in:
@@ -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,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
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);
|
||||
|
||||
@@ -62,4 +62,9 @@ public interface IRealtimeNotifier
|
||||
|
||||
/// <summary>Новый комментарий или смена статуса — пушится автору тикета (не всем участникам треда).</summary>
|
||||
Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>PaidUntil/BillingSuspended или защита на время проверки заявки изменились — фронт
|
||||
/// (страница /billing) инвалидирует свой запрос статуса. Без конкретных данных в пейлоаде —
|
||||
/// клиент сам перезапросит актуальное состояние (см. BillingConfigResumer).</summary>
|
||||
Task NotifyBillingStatusChangedAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user