using Microsoft.EntityFrameworkCore; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; using PnvPanel.Domain.Configs; namespace PnvPanel.Application.Configs.Create; public sealed class CreateVpnConfigCommandHandler( IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, ICurrentUser currentUser ) : ICommandHandler> { public async Task> Handle( CreateVpnConfigCommand command, CancellationToken cancellationToken ) { if (currentUser.UserId is not { } userId) return Result.Failure(AuthErrors.Unauthorized); var profile = await identityService.GetProfileAsync(userId, cancellationToken); if (profile is null) return Result.Failure(AuthErrors.Unauthorized); var inbound = await dbContext .Inbounds.AsNoTracking() .FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken); if (inbound is null || !inbound.IsPublished) return Result.Failure(ConfigErrors.InboundNotAvailable); if (!inbound.AllowedRoleIds.Contains(profile.RoleId)) return Result.Failure(ConfigErrors.InboundNotAllowedForRole); var node = await dbContext .Nodes.AsNoTracking() .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); // Node.Status — это кэш периодического health-check'а (раз в 2 минуты), а не проверка // в реальном времени: блокировать по нему создание конфига значит ловить ложные отказы на // временных сетевых сбоях пробника. Реальную недоступность ловит AddClientAsync ниже — // тот бьёт в панель прямо сейчас и возвращает честную ошибку с компенсацией. if (node is null || !node.IsEnabled) return Result.Failure(ConfigErrors.NodeDisabled); var config = VpnConfig.Create(userId, inbound.Id, inbound.Protocol, command.Label); var reserveResult = await ReserveQuotaSlotAsync( userId, profile.MaxConfigs, config, cancellationToken ); if (!reserveResult.IsSuccess) return Result.Failure(reserveResult.Error); var addResult = await gateway.AddClientAsync( node, inbound.RemoteInboundId, inbound.Protocol, config.ClientEmail, config.Label ?? config.ClientEmail, profile.MaxIpLimit, cancellationToken ); if (!addResult.IsSuccess) { // Компенсация: квота была зарезервирована локально, но клиент в 3x-ui не создался — // откатываем резервирование, наружу не оставляем "мёртвую" запись. dbContext.VpnConfigs.Remove(config); await dbContext.SaveChangesAsync(cancellationToken); return Result.Failure(addResult.Error); } config.AssignRemoteClient(addResult.Value); await dbContext.SaveChangesAsync(cancellationToken); return Result.Success(VpnConfigDto.FromDomain(config, inbound)); } /// /// Проверка квоты + резервирование строки — под pg_advisory_xact_lock (гонки параллельных /// созданий, см. CLAUDE.md). Лок держится только на время короткой транзакции count+insert, /// НЕ на время внешнего HTTP-вызова к 3x-ui — иначе рискуем держать соединение к БД открытым /// на секунды под внешним I/O. /// private async Task ReserveQuotaSlotAsync( Guid userId, int maxConfigs, VpnConfig config, CancellationToken cancellationToken ) { await using var transaction = await dbContext.Database.BeginTransactionAsync( cancellationToken ); await dbContext.Database.ExecuteSqlInterpolatedAsync( $"SELECT pg_advisory_xact_lock(hashtext({userId.ToString()}))", cancellationToken ); var activeCount = await dbContext.VpnConfigs.CountAsync( c => c.UserId == userId && c.Status == ConfigStatus.Active, cancellationToken ); if (maxConfigs != RoleQuota.Unlimited && activeCount >= maxConfigs) { await transaction.RollbackAsync(cancellationToken); return Result.Failure(ConfigErrors.QuotaExceeded); } dbContext.VpnConfigs.Add(config); await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Result.Success(); } }