- 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.
134 lines
5.7 KiB
C#
134 lines
5.7 KiB
C#
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();
|
||
}
|
||
}
|