Files
PnvPanel/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs
T
Leonid Pershin b2ae358250
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
Implement billing functionality and enhance role management
- Introduced billing capabilities, allowing users to request payments for subscription periods (3/6/12 months) with admin approval via Telegram.
- Updated role management to include a `BillingEnabled` property, preventing billing for admin roles.
- Enhanced the `CreateRoleCommand` and `UpdateRoleCommand` to accept billing parameters, ensuring proper handling during role creation and updates.
- Added new endpoints for billing management and integrated billing checks into VPN config creation to enforce payment requirements.
- Updated related services, models, and tests to support the new billing features, ensuring comprehensive coverage and functionality.
- Enhanced documentation to reflect the new billing processes and role management changes.
2026-07-19 01:38:16 +03:00

134 lines
5.7 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);
// Не даём обойти приостановку за неуплату созданием нового конфига — см. BillingService.
if (
profile.BillingEnabled
&& (profile.BillingPaidUntil is null || profile.BillingPaidUntil < DateTimeOffset.UtcNow)
)
return Result.Failure<VpnConfigDto>(ConfigErrors.BillingRequired);
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);
if (profile.BillingEnabled)
config.SetBillingExpiry(profile.BillingPaidUntil);
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();
}
}