- 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.
216 lines
9.4 KiB
C#
216 lines
9.4 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using PnvPanel.Application.Common.Interfaces;
|
|
using PnvPanel.Domain.Billing;
|
|
using PnvPanel.Domain.Configs;
|
|
|
|
namespace PnvPanel.Application.Billing;
|
|
|
|
/// <summary>
|
|
/// Общая синхронизация панели 3x-ui со статусом оплаты: продление (ResumeConfigsAsync), временная
|
|
/// защита на время рассмотрения заявки админом (ProtectPendingConfigsAsync) и приостановка за неуплату
|
|
/// (SuspendConfigsAsync). Xray проверяет expiryTime клиента сам, независимо от нашего локального
|
|
/// статуса — поэтому "просто не гасить, пока заявка на проверке" недостаточно: если реальный
|
|
/// BillingPaidUntil уже в прошлом, панель заблокирует клиента сама, пока мы явно не подвинем
|
|
/// expiryTime вперёд. По той же причине везде передаём enable И expiresAt вместе (см.
|
|
/// IXuiPanelGateway.UpdateClientAsync) — по факту эксплуатации одного enable недостаточно.
|
|
/// </summary>
|
|
public static class BillingConfigResumer
|
|
{
|
|
/// <summary>Подтверждённая оплата/продление/гифт — реальный новый срок. Пушится на панель для
|
|
/// ЛЮБОГО статуса конфига, не только Expired: пользователь мог доплатить/продлить заранее, пока
|
|
/// конфиг ещё Active — панель должна узнать новый срок сразу, а не только когда конфиг реально
|
|
/// просрочится.</summary>
|
|
public static async Task ResumeConfigsAsync(
|
|
IAppDbContext dbContext,
|
|
IXuiPanelGateway gateway,
|
|
IRealtimeNotifier notifier,
|
|
ILogger logger,
|
|
Guid userId,
|
|
DateTimeOffset newPaidUntil,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var configs = await ActiveOrExpiredConfigsAsync(dbContext, userId, cancellationToken);
|
|
|
|
foreach (var config in configs)
|
|
{
|
|
var wasExpired = config.Status == ConfigStatus.Expired;
|
|
var pushed = await PushExpiryAsync(
|
|
dbContext,
|
|
gateway,
|
|
logger,
|
|
config,
|
|
enable: true,
|
|
newPaidUntil,
|
|
"extending billing",
|
|
cancellationToken
|
|
);
|
|
|
|
if (wasExpired && pushed)
|
|
{
|
|
config.Resume();
|
|
await notifier.NotifyConfigStatusChangedAsync(
|
|
config.UserId,
|
|
config.Id,
|
|
config.Status,
|
|
cancellationToken
|
|
);
|
|
}
|
|
|
|
config.SetBillingExpiry(newPaidUntil);
|
|
}
|
|
|
|
await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken);
|
|
}
|
|
|
|
/// <summary>Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим
|
|
/// на панели на время рассмотрения (expiresAt = сейчас + BillingSettings.GraceDays), не трогая
|
|
/// Status/ExpiresAt в БД — это временная мера до Confirm (ResumeConfigsAsync проставит настоящий
|
|
/// срок) или Reject (SuspendConfigsAsync вернёт как было). Вызывается и сразу при отметке "я
|
|
/// оплатил" (MarkPaymentSentCommandHandler), и на каждом тике BillingService, пока заявка висит —
|
|
/// идемпотентно, каждый вызов просто продлевает грейс ещё на GraceDays от текущего момента.</summary>
|
|
public static async Task ProtectPendingConfigsAsync(
|
|
IAppDbContext dbContext,
|
|
IXuiPanelGateway gateway,
|
|
IRealtimeNotifier notifier,
|
|
ILogger logger,
|
|
Guid userId,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var settings = await dbContext.BillingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
|
|
var graceUntil = DateTimeOffset.UtcNow.AddDays(settings?.GraceDays ?? BillingSettings.DefaultGraceDays);
|
|
|
|
var configs = await ActiveOrExpiredConfigsAsync(dbContext, userId, cancellationToken);
|
|
|
|
foreach (var config in configs)
|
|
{
|
|
await PushExpiryAsync(
|
|
dbContext,
|
|
gateway,
|
|
logger,
|
|
config,
|
|
enable: true,
|
|
graceUntil,
|
|
"protecting pending payment for",
|
|
cancellationToken
|
|
);
|
|
}
|
|
|
|
await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken);
|
|
}
|
|
|
|
/// <summary>Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной
|
|
/// реакции на отклонение заявки админом (RejectPaymentRequestCommandHandler), чтобы не ждать
|
|
/// следующего часового тика. Проходит и по уже Expired конфигам — они могли быть временно
|
|
/// "защищены" ProtectPendingConfigsAsync (enable/expiresAt на панели уехали вперёд, а статус в БД
|
|
/// остался как был), и при отклонении заявки эту защиту нужно снять с панели тоже, а не только
|
|
/// локально.</summary>
|
|
public static async Task SuspendConfigsAsync(
|
|
IAppDbContext dbContext,
|
|
IXuiPanelGateway gateway,
|
|
IRealtimeNotifier notifier,
|
|
ILogger logger,
|
|
Guid userId,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var expiredAt = DateTimeOffset.UtcNow.AddDays(-1);
|
|
var configs = await ActiveOrExpiredConfigsAsync(dbContext, userId, cancellationToken);
|
|
|
|
foreach (var config in configs)
|
|
{
|
|
var wasActive = config.Status == ConfigStatus.Active;
|
|
var pushed = await PushExpiryAsync(
|
|
dbContext,
|
|
gateway,
|
|
logger,
|
|
config,
|
|
enable: false,
|
|
expiredAt,
|
|
"suspending for non-payment",
|
|
cancellationToken
|
|
);
|
|
|
|
if (!wasActive || !pushed)
|
|
continue;
|
|
|
|
config.Suspend();
|
|
await notifier.NotifyConfigStatusChangedAsync(
|
|
config.UserId,
|
|
config.Id,
|
|
config.Status,
|
|
cancellationToken
|
|
);
|
|
}
|
|
|
|
await notifier.NotifyBillingStatusChangedAsync(userId, cancellationToken);
|
|
}
|
|
|
|
private static Task<List<VpnConfig>> ActiveOrExpiredConfigsAsync(
|
|
IAppDbContext dbContext,
|
|
Guid userId,
|
|
CancellationToken cancellationToken
|
|
) =>
|
|
dbContext
|
|
.VpnConfigs.Where(c =>
|
|
c.UserId == userId
|
|
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
|
|
)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
/// <summary>Возвращает false только если панель реально отвергла обновление (нода недоступна и
|
|
/// т.п.) — вызывающий тогда пропускает смену локального статуса, чтобы не разойтись с панелью
|
|
/// (подхватится следующим циклом/событием). Если у конфига нет инбаунда/ноды вовсе — на панели
|
|
/// нечего обновлять, но локальный статус менять можно (как и раньше в BillingService/
|
|
/// BillingConfigResumer).</summary>
|
|
private static async Task<bool> PushExpiryAsync(
|
|
IAppDbContext dbContext,
|
|
IXuiPanelGateway gateway,
|
|
ILogger logger,
|
|
VpnConfig config,
|
|
bool enable,
|
|
DateTimeOffset expiresAt,
|
|
string action,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var inbound = await dbContext
|
|
.Inbounds.AsNoTracking()
|
|
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
|
var node = inbound is null
|
|
? null
|
|
: await dbContext
|
|
.Nodes.AsNoTracking()
|
|
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
|
|
|
if (inbound is null || node is null)
|
|
return true;
|
|
|
|
var result = await gateway.UpdateClientAsync(
|
|
node,
|
|
inbound.RemoteInboundId,
|
|
config.ClientExternalId,
|
|
config.Protocol,
|
|
name: null,
|
|
enable,
|
|
expiresAt,
|
|
cancellationToken
|
|
);
|
|
|
|
if (result.IsSuccess)
|
|
return true;
|
|
|
|
logger.LogWarning(
|
|
"Failed to update client for config {ConfigId} on node {NodeId} while {Action} user {UserId}: {Error}",
|
|
config.Id,
|
|
node.Id,
|
|
action,
|
|
config.UserId,
|
|
result.Error
|
|
);
|
|
return false;
|
|
}
|
|
}
|