Files
PnvPanel/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs
T
Leonid Pershin d30c958dc4
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
Refactor VPN config creation logic to improve node status handling
- Removed the dependency on `Node.Status` for VPN config creation, addressing potential false negatives due to cached health-check data.
- Updated documentation to clarify that `Node.Status` is a diagnostic indicator and not a gate for config creation, ensuring accurate understanding of node availability checks.
- Enhanced comments in the code to explain the rationale behind the changes, improving maintainability and clarity for future developers.
2026-07-15 15:53:47 +03:00

125 lines
5.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CreateVpnConfigCommand, Result<VpnConfigDto>>
{
public async Task<Result<VpnConfigDto>> Handle(
CreateVpnConfigCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<VpnConfigDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<VpnConfigDto>(AuthErrors.Unauthorized);
var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken);
if (inbound is null || !inbound.IsPublished)
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAvailable);
if (!inbound.AllowedRoleIds.Contains(profile.RoleId))
return Result.Failure<VpnConfigDto>(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<VpnConfigDto>(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<VpnConfigDto>(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<VpnConfigDto>(addResult.Error);
}
config.AssignRemoteClient(addResult.Value);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Success(VpnConfigDto.FromDomain(config, inbound));
}
/// <summary>
/// Проверка квоты + резервирование строки — под pg_advisory_xact_lock (гонки параллельных
/// созданий, см. CLAUDE.md). Лок держится только на время короткой транзакции count+insert,
/// НЕ на время внешнего HTTP-вызова к 3x-ui — иначе рискуем держать соединение к БД открытым
/// на секунды под внешним I/O.
/// </summary>
private async Task<Result> 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();
}
}