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
@@ -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);
}