using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Domain.Billing; using PnvPanel.Domain.Configs; namespace PnvPanel.Application.Billing; /// /// Общая синхронизация панели 3x-ui со статусом оплаты: продление (ResumeConfigsAsync), временная /// защита на время рассмотрения заявки админом (ProtectPendingConfigsAsync) и приостановка за неуплату /// (SuspendConfigsAsync). Xray проверяет expiryTime клиента сам, независимо от нашего локального /// статуса — поэтому "просто не гасить, пока заявка на проверке" недостаточно: если реальный /// BillingPaidUntil уже в прошлом, панель заблокирует клиента сама, пока мы явно не подвинем /// expiryTime вперёд. По той же причине везде передаём enable И expiresAt вместе (см. /// IXuiPanelGateway.UpdateClientAsync) — по факту эксплуатации одного enable недостаточно. /// public static class BillingConfigResumer { /// Подтверждённая оплата/продление/гифт — реальный новый срок. Пушится на панель для /// ЛЮБОГО статуса конфига, не только Expired: пользователь мог доплатить/продлить заранее, пока /// конфиг ещё Active — панель должна узнать новый срок сразу, а не только когда конфиг реально /// просрочится. 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); } /// Заявка на оплату ждёт решения админа (AwaitingConfirmation) — держим клиента рабочим /// на панели на время рассмотрения (expiresAt = сейчас + BillingSettings.GraceDays), не трогая /// Status/ExpiresAt в БД — это временная мера до Confirm (ResumeConfigsAsync проставит настоящий /// срок) или Reject (SuspendConfigsAsync вернёт как было). Вызывается и сразу при отметке "я /// оплатил" (MarkPaymentSentCommandHandler), и на каждом тике BillingService, пока заявка висит — /// идемпотентно, каждый вызов просто продлевает грейс ещё на GraceDays от текущего момента. 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); } /// Приостановка за неуплату — общая для фоновой джобы (BillingService) и немедленной /// реакции на отклонение заявки админом (RejectPaymentRequestCommandHandler), чтобы не ждать /// следующего часового тика. Проходит и по уже Expired конфигам — они могли быть временно /// "защищены" ProtectPendingConfigsAsync (enable/expiresAt на панели уехали вперёд, а статус в БД /// остался как был), и при отклонении заявки эту защиту нужно снять с панели тоже, а не только /// локально. 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> ActiveOrExpiredConfigsAsync( IAppDbContext dbContext, Guid userId, CancellationToken cancellationToken ) => dbContext .VpnConfigs.Where(c => c.UserId == userId && (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired) ) .ToListAsync(cancellationToken); /// Возвращает false только если панель реально отвергла обновление (нода недоступна и /// т.п.) — вызывающий тогда пропускает смену локального статуса, чтобы не разойтись с панелью /// (подхватится следующим циклом/событием). Если у конфига нет инбаунда/ноды вовсе — на панели /// нечего обновлять, но локальный статус менять можно (как и раньше в BillingService/ /// BillingConfigResumer). private static async Task 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; } }