From 979eddf72e27dbafb95f88b52d031bd145f22e19 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 19 Jul 2026 18:58:36 +0300 Subject: [PATCH] Refactor client update handling to support nullable parameters for name and expiration - Updated the `UpdateClientAsync` method in `IXuiPanelGateway` to accept nullable parameters for `name` and `expiresAt`, allowing for more flexible client management without unintended modifications. - Adjusted the `BlockUserCommandHandler`, `UnblockUserCommandHandler`, and other related command handlers to utilize the new nullable parameters, ensuring that client names remain unchanged during block/unblock operations and that expiration dates are managed correctly. - Enhanced the billing and configuration handling to reflect the new logic for managing client states based on expiration rather than enabling/disabling, improving reliability in client status management. - Updated tests to cover the new behavior and ensure proper functionality across the application. --- .../Admin/Users/BlockUserCommandHandler.cs | 6 +- .../Admin/Users/UnblockUserCommandHandler.cs | 6 +- .../Billing/BillingConfigResumer.cs | 9 +- .../Common/Interfaces/IXuiPanelGateway.cs | 29 +++- .../Create/CreateVpnConfigCommandHandler.cs | 2 + .../Edit/EditVpnConfigCommandHandler.cs | 6 +- .../Rotate/RotateVpnConfigCommandHandler.cs | 3 + .../BackgroundJobs/BillingService.cs | 9 +- .../Xui/XuiPanelGateway.cs | 12 +- ...onfirmPaymentRequestCommandHandlerTests.cs | 8 +- ...roveExtensionRequestCommandHandlerTests.cs | 3 +- .../Users/BlockUserCommandHandlerTests.cs | 17 ++- .../Users/UnblockUserCommandHandlerTests.cs | 7 +- .../CreateVpnConfigCommandHandlerTests.cs | 128 ++++++++++++++++++ .../RotateVpnConfigCommandHandlerTests.cs | 2 + .../TestSupport/FakeXuiPanelGateway.cs | 6 +- docs/domain-model.md | 28 +++- .../features/configs/CreateConfigDialog.tsx | 11 ++ frontend/src/routes/dashboard.tsx | 15 +- frontend/src/shared/lib/i18n.ts | 4 + 20 files changed, 282 insertions(+), 29 deletions(-) create mode 100644 backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs diff --git a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs index 20cc85e..9f2980a 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs @@ -42,13 +42,17 @@ public sealed class BlockUserCommandHandler( if (inbound is not null && node is not null) { + // name: null — не трогаем текущее имя клиента в панели, это не переименование. + // expiresAt: null — блокировка админом отдельная ось от биллинга (см. + // IXuiPanelGateway.UpdateClientAsync), срок оплаты не трогаем. var updateResult = await gateway.UpdateClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, - config.Label ?? config.ClientEmail, + name: null, enable: false, + expiresAt: null, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs index 065f169..18b61a1 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs @@ -47,13 +47,17 @@ public sealed class UnblockUserCommandHandler( if (inbound is not null && node is not null) { + // name: null — не трогаем текущее имя клиента в панели, это не переименование. + // expiresAt: null — блокировка админом отдельная ось от биллинга (см. + // IXuiPanelGateway.UpdateClientAsync), срок оплаты не трогаем. var updateResult = await gateway.UpdateClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, - config.Label ?? config.ClientEmail, + name: null, enable: true, + expiresAt: null, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs b/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs index 427c3f5..52f535b 100644 --- a/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs +++ b/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs @@ -47,13 +47,18 @@ internal static class BillingConfigResumer if (inbound is not null && node is not null) { + // Возвращаем через expiryTime = новый newPaidUntil, а не enable:true — тот же + // механизм приостановки, что и у BillingService (см. IXuiPanelGateway.UpdateClientAsync): + // переписываем просроченную дату на настоящую, панель/Xray сами перестают считать + // клиента истёкшим. name: null — не переименовываем клиента. var updateResult = await gateway.UpdateClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, - config.Label ?? config.ClientEmail, - enable: true, + name: null, + enable: null, + expiresAt: newPaidUntil, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs index 521f5bf..0c2282b 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs @@ -36,6 +36,8 @@ public interface IXuiPanelGateway /// Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks). /// — лимит одновременных IP клиента (квота роли, см. AppRole.MaxIpLimit); /// -1 (RoleQuota.Unlimited) означает без лимита — гейтвей сам переводит его в нативное значение 3x-ui. + /// — дата, до которой клиент активен в самой панели (billing-роли — + /// AppUser.BillingPaidUntil на момент создания); null — без ограничения по сроку. /// Task> AddClientAsync( Node node, @@ -44,6 +46,7 @@ public interface IXuiPanelGateway string clientEmail, string clientName, int limitIp, + DateTimeOffset? expiresAt, CancellationToken cancellationToken ); @@ -55,13 +58,35 @@ public interface IXuiPanelGateway CancellationToken cancellationToken ); + /// + /// Все параметры, кроме обязательных идентификаторов, — "не трогать, если null" (см. ThreeXui.Net + /// UpdateClientRequest: "All fields optional — null means leave as is"). + /// + /// — НЕ отдельная косметическая метка: у клиента 3x-ui нет своего поля + /// remark/comment, поэтому ThreeXui.Net пишет её в то же поле settings.clients[].email, + /// которое AddClientAsync изначально заполняет стабильным ClientEmail. Передавайте null + /// для любого вызова, не являющегося намеренным переименованием — иначе затрёте панельный + /// identity-идентификатор клиента (и сломаете сопоставление по email в GetClientTrafficAsync). + /// Единственный легитимный вызывающий с непустым name — EditVpnConfigCommandHandler. + /// + /// + /// — используется для приостановки/возврата за неуплату + /// (BillingService/BillingConfigResumer): дата в прошлом делает клиента просроченным для самой + /// панели/Xray независимо от (по факту тестирования — переключение + /// enable ненадёжно останавливает уже установленные соединения, а expiryTime — надёжно), дата в + /// будущем (новый AppUser.BillingPaidUntil) снимает приостановку. Блокировка/разблокировка + /// админом (BlockUserCommandHandler/UnblockUserCommandHandler) — отдельная ось, передаёт + /// expiresAt: null и управляет только . + /// + /// Task UpdateClientAsync( Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, - string name, - bool enable, + string? name, + bool? enable, + DateTimeOffset? expiresAt, CancellationToken cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs index c762ba7..a1f67d6 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs @@ -73,6 +73,8 @@ public sealed class CreateVpnConfigCommandHandler( config.ClientEmail, config.Label ?? config.ClientEmail, profile.MaxIpLimit, + // BillingRequired-проверка выше гарантирует, что при BillingEnabled PaidUntil уже в будущем. + expiresAt: profile.BillingEnabled ? profile.BillingPaidUntil : null, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs index b492d9c..2dfacbb 100644 --- a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs @@ -42,13 +42,17 @@ public sealed class EditVpnConfigCommandHandler( .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); if (node is not null) { + // enable/expiresAt: null — переименование не должно снимать приостановку/блокировку + // клиента (раньше здесь стояло enable:true и молча реактивировало погашенный за + // неуплату/блокировку конфиг просто оттого, что пользователь его переименовал). var updateResult = await gateway.UpdateClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, command.Label, - enable: true, + enable: null, + expiresAt: null, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs index 2f6f99f..5bbba25 100644 --- a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs @@ -56,6 +56,9 @@ public sealed class RotateVpnConfigCommandHandler( newClientEmail, config.Label ?? newClientEmail, profile.MaxIpLimit, + // Переносим уже действующий срок (billing paidUntil) на нового клиента — ротация не должна + // ни продлевать, ни сбрасывать оплаченный период. + expiresAt: config.ExpiresAt, cancellationToken ); diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs index d850f2b..270c7c7 100644 --- a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs @@ -139,13 +139,18 @@ public sealed class BillingService( if (inbound is not null && node is not null) { + // Приостанавливаем через expiryTime в прошлом, а не enable:false — переключение enable + // ненадёжно останавливает уже установленные соединения на стороне Xray/панели (см. + // IXuiPanelGateway.UpdateClientAsync), просроченный expiryTime — надёжно и независимо + // от enable. name: null — не переименовываем клиента. var updateResult = await gateway.UpdateClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, - config.Label ?? config.ClientEmail, - enable: false, + name: null, + enable: null, + expiresAt: DateTimeOffset.UtcNow.AddDays(-1), cancellationToken ); diff --git a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs index a128d09..c6fcda5 100644 --- a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs +++ b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs @@ -92,6 +92,7 @@ internal sealed class XuiPanelGateway( string clientEmail, string clientName, int limitIp, + DateTimeOffset? expiresAt, CancellationToken cancellationToken ) { @@ -105,7 +106,7 @@ internal sealed class XuiPanelGateway( clientEmail, ToRemoteProtocol(protocol), limitIp == RoleQuota.Unlimited ? 0 : limitIp, - null + expiresAt ); var result = await client.AddClientAsync(inboundRemoteId, request, cancellationToken); return Result.Success(result.ExternalClientId); @@ -150,16 +151,17 @@ internal sealed class XuiPanelGateway( string inboundRemoteId, string clientExternalId, VpnProtocol protocol, - string name, - bool enable, + string? name, + bool? enable, + DateTimeOffset? expiresAt, CancellationToken cancellationToken ) { try { var client = GetClient(node); - // deviceLimit: null — не трогаем то, что уже стоит на клиенте в панели (см. AddClientAsync). - var request = new UpdateClientRequest(null, null, enable, name); + // limitIp: null — не трогаем то, что уже стоит на клиенте в панели (см. AddClientAsync). + var request = new UpdateClientRequest(null, expiresAt, enable, name); await client.UpdateClientAsync( inboundRemoteId, clientExternalId, diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs index df83323..845dd00 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs @@ -167,7 +167,8 @@ public class ConfirmPaymentRequestCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), - enable: true, + Arg.Any(), + Arg.Any(), Arg.Any() ) .Returns(Result.Success()); @@ -192,6 +193,8 @@ public class ConfirmPaymentRequestCommandHandlerTests Assert.NotNull(expiredConfig.ExpiresAt); Assert.NotNull(activeConfig.ExpiresAt); Assert.Equal(expiredConfig.ExpiresAt, activeConfig.ExpiresAt); + // enable: null — возврат из приостановки теперь идёт через expiresAt (новый newPaidUntil), + // а не через переключение enable (см. IXuiPanelGateway.UpdateClientAsync). await _gateway .Received(1) .UpdateClientAsync( @@ -200,7 +203,8 @@ public class ConfirmPaymentRequestCommandHandlerTests "ext-1", VpnProtocol.Vless, Arg.Any(), - enable: true, + null, + Arg.Is(d => d == expiredConfig.ExpiresAt), Arg.Any() ); } diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs index 221e22a..2b8ee0c 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs @@ -128,7 +128,8 @@ public class ApproveExtensionRequestCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), - enable: true, + Arg.Any(), + Arg.Any(), Arg.Any() ) .Returns(Result.Success()); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs index 0fd8b96..0a00b2e 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs @@ -54,7 +54,8 @@ public class BlockUserCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any() ); } @@ -91,7 +92,8 @@ public class BlockUserCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any() ) .Returns(Result.Success()); @@ -111,6 +113,8 @@ public class BlockUserCommandHandlerTests Assert.True(result.IsSuccess); Assert.Equal(ConfigStatus.Disabled, config.Status); + // name: null — блокировка не переименовывает клиента; expiresAt: null — блокировка не трогает + // срок оплаты (см. IXuiPanelGateway.UpdateClientAsync). await _gateway .Received(1) .UpdateClientAsync( @@ -118,8 +122,9 @@ public class BlockUserCommandHandlerTests inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, - "my-config", + null, enable: false, + expiresAt: null, Arg.Any() ); await _notifier @@ -168,7 +173,8 @@ public class BlockUserCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any() ); } @@ -205,7 +211,8 @@ public class BlockUserCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any() ) .Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна."))); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs index c9bb10c..7065bc8 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs @@ -54,7 +54,8 @@ public class UnblockUserCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any() ) .Returns(Result.Success()); @@ -81,6 +82,7 @@ public class UnblockUserCommandHandlerTests config.Protocol, Arg.Any(), true, + null, Arg.Any() ); await _notifier @@ -155,7 +157,8 @@ public class UnblockUserCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any() ) .Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна."))); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs new file mode 100644 index 0000000..16126da --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs @@ -0,0 +1,128 @@ +using NSubstitute; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Configs; +using PnvPanel.Application.Configs.Create; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Infrastructure.Persistence; +using Xunit; + +namespace PnvPanel.Application.Tests.Configs.Create; + +/// +/// Только ветки ДО ReserveQuotaSlotAsync (billing-guard) — дальше хендлер уходит в +/// pg_advisory_xact_lock, который InMemory-провайдер не поддерживает (см. CLAUDE.md/ +/// ConfigQuotaTests в IntegrationTests для позитивного пути и гонок). +/// +public class CreateVpnConfigCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly IXuiPanelGateway _gateway = Substitute.For(); + + private static CurrentUserProfile Profile( + Guid userId, + Guid roleId, + bool billingEnabled, + DateTimeOffset? billingPaidUntil + ) => + new( + userId, + "alice", + roleId, + "premium", + IsActivated: true, + IsBlocked: false, + MaxConfigs: 5, + MaxIpLimit: 3, + SubscriptionToken: "sub-token", + BillingEnabled: billingEnabled, + BillingPaidUntil: billingPaidUntil, + BillingSuspended: false + ); + + private async Task<(Inbound inbound, Guid roleId)> SeedAllowedInboundAsync(AppDbContext dbContext) + { + var roleId = Guid.NewGuid(); + var node = Domain.Nodes.Node.Register( + "node-1", + new Uri("https://node1.example.com"), + new Domain.Nodes.NodeCredentials("admin", "protected"), + null + ); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + inbound.Publish(null, [roleId]); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + await dbContext.SaveChangesAsync(CancellationToken.None); + + return (inbound, roleId); + } + + [Fact] + public async Task Handle_WhenBillingEnabledAndPaidUntilExpired_ReturnsBillingRequired() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var (inbound, roleId) = await SeedAllowedInboundAsync(dbContext); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, roleId, billingEnabled: true, billingPaidUntil: DateTimeOffset.UtcNow.AddDays(-1))); + + var handler = new CreateVpnConfigCommandHandler( + dbContext, + _identityService, + _gateway, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CreateVpnConfigCommand(inbound.Id, null), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(ConfigErrors.BillingRequired, result.Error); + await _gateway + .DidNotReceive() + .AddClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task Handle_WhenBillingEnabledAndPaidUntilNull_ReturnsBillingRequired() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var (inbound, roleId) = await SeedAllowedInboundAsync(dbContext); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, roleId, billingEnabled: true, billingPaidUntil: null)); + + var handler = new CreateVpnConfigCommandHandler( + dbContext, + _identityService, + _gateway, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CreateVpnConfigCommand(inbound.Id, null), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(ConfigErrors.BillingRequired, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs index 09533c7..6e20236 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs @@ -65,6 +65,7 @@ public class RotateVpnConfigCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any() ) .Returns(Result.Success("new-external-id")); @@ -209,6 +210,7 @@ public class RotateVpnConfigCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any() ) .Returns(Result.Failure(gatewayError)); diff --git a/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs b/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs index d02a605..c9d7bde 100644 --- a/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs +++ b/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs @@ -38,6 +38,7 @@ public sealed class FakeXuiPanelGateway : IXuiPanelGateway string clientEmail, string clientName, int limitIp, + DateTimeOffset? expiresAt, CancellationToken cancellationToken ) => Task.FromResult(Result.Success(Guid.NewGuid().ToString())); @@ -54,8 +55,9 @@ public sealed class FakeXuiPanelGateway : IXuiPanelGateway string inboundRemoteId, string clientExternalId, VpnProtocol protocol, - string name, - bool enable, + string? name, + bool? enable, + DateTimeOffset? expiresAt, CancellationToken cancellationToken ) => Task.FromResult(Result.Success()); diff --git a/docs/domain-model.md b/docs/domain-model.md index 3ef60f0..6728391 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -492,7 +492,7 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты висит на подтверждении админом, а срок истёк» из требований: конфиги не гасятся, пока админ не решит (не по вине пользователя, что админ не успел проверить оплату); - `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить все `Active` - конфиги (`Suspend()` → `Expired`, гейтвей `UpdateClientAsync(enable:false)`, идемпотентно как в + конфиги (`Suspend()` → `Expired`, гейтвей `UpdateClientAsync(expiresAt: вчера)`, идемпотентно как в `BlockUserCommandHandler`), `AppUser.BillingSuspended = true`, Telegram-уведомление пользователю, `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже suspended) — только идемпотентная досуспензия «зависших» `Active`-конфигов (самовосстановление после недоступности @@ -500,9 +500,33 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты - до истечения ≤ 3 дней и предупреждение для этого `PaidUntil` ещё не отправлено (`BillingLastWarnedForPaidUntil != PaidUntil`) → Telegram-предупреждение, отметка отправки. +#### Приостановка/возврат за неуплату — через expiresAt, не enable + +Приостановка за неуплату (`BillingService`) и возврат (`BillingConfigResumer`) управляют панельным +клиентом через `IXuiPanelGateway.UpdateClientAsync(..., expiresAt: ..., enable: null, ...)`, а не через +`enable: false/true` — по факту эксплуатации переключение `enable` ненадёжно останавливает уже +установленные соединения на стороне Xray/панели, а просроченный `expiryTime` — надёжно, и не зависит +от `enable`. Конкретно: +- **Приостановка**: `expiresAt = UtcNow.AddDays(-1)` — гарантированно просроченная дата, `enable` не + трогаем (`null`, «оставить как есть» — см. ThreeXui.Net `UpdateClientRequest`). +- **Возврат**: `expiresAt = newPaidUntil` (реальный новый срок оплаты, не «снять ограничение» на + бесконечность) — так панель/Xray сама несёт актуальный срок: если `BillingService` вдруг пропустит + тик, панель всё равно перестанет пускать по истечении этой даты независимо от приложения. +- **Создание** (`CreateVpnConfigCommandHandler`) уже пушит `expiresAt = profile.BillingPaidUntil` при + `BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок, не «без + ограничения» до первого цикла `BillingService`. +- **Ротация** (`RotateVpnConfigCommandHandler`) переносит текущий `config.ExpiresAt` на нового клиента + — ротация не должна ни продлевать, ни сбрасывать оплаченный период. +- Блокировка/разблокировка админом (`Disable()`/`Enable()`) — отдельная ось, управляет только + `enable`, `expiresAt: null` (не трогает срок оплаты). Переименование (`EditVpnConfigCommandHandler`) + — `enable`/`expiresAt: null` (раньше по ошибке форсировало `enable:true`, тем самым молча снимая + приостановку/блокировку простым переименованием конфига — исправлено). + `CreateVpnConfigCommandHandler` дополнительно не даёт создать **новый** конфиг, если роль billing и оплата просрочена (`ConfigErrors.BillingRequired`) — иначе приостановку можно было бы обойти -созданием свежего конфига. +созданием свежего конфига. Фронт (`dashboard.tsx`) зеркалит эту же проверку и скрывает кнопку создания +конфига заранее, а не только реагирует на 403 от сервера (см. `CreateConfigDialog.tsx` — safety-net на +случай гонки состояний). `GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок, diff --git a/frontend/src/features/configs/CreateConfigDialog.tsx b/frontend/src/features/configs/CreateConfigDialog.tsx index 076992e..e02de73 100644 --- a/frontend/src/features/configs/CreateConfigDialog.tsx +++ b/frontend/src/features/configs/CreateConfigDialog.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Plus } from 'lucide-react' import { toast } from '@/shared/ui/toast-store' @@ -15,6 +16,7 @@ import { createConfig } from './api' export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto[] }) { const { t } = useTranslation() const queryClient = useQueryClient() + const navigate = useNavigate() const [open, setOpen] = useState(false) const [inboundId, setInboundId] = useState('') const [label, setLabel] = useState('') @@ -29,6 +31,15 @@ export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto setLabel('') }, onError: (error) => { + // Safety-net: дашборд уже скрывает эту кнопку при истёкшей оплате (см. dashboard.tsx), но + // между открытием страницы и отправкой формы биллинг мог протухнуть — сервер всё равно + // проверяет (ConfigErrors.BillingRequired) и на всякий случай отправляем на оплату. + if (error instanceof HttpError && error.title === 'Configs.BillingRequired') { + toast.error(t('configs.billingRequiredNotice')) + setOpen(false) + void navigate({ to: '/billing' }) + return + } const message = error instanceof HttpError && error.status === 409 ? t('configs.quotaExceeded') : t('auth.genericError') toast.error(message) diff --git a/frontend/src/routes/dashboard.tsx b/frontend/src/routes/dashboard.tsx index dd93da0..59f6c84 100644 --- a/frontend/src/routes/dashboard.tsx +++ b/frontend/src/routes/dashboard.tsx @@ -30,6 +30,12 @@ function ConfigsList() { const inboundsQuery = useQuery({ queryKey: ['available-inbounds'], queryFn: listAvailableInbounds }) const billingStatusQuery = useQuery({ queryKey: ['my-billing-status'], queryFn: getMyBillingStatus }) + // Зеркалит серверную проверку в CreateVpnConfigCommandHandler (ConfigErrors.BillingRequired) — + // роль с биллингом и просроченной/неоплаченной подпиской не может создавать новые конфиги. + const billing = billingStatusQuery.data + const billingBlocksCreate = + !!billing?.billingEnabled && (billing.paidUntil == null || new Date(billing.paidUntil) < new Date()) + return (
@@ -52,7 +58,14 @@ function ConfigsList() { )}
- {inboundsQuery.data?.length === 0 ? ( + {billingBlocksCreate ? ( +
+

{t('configs.billingRequiredNotice')}

+ + {t('configs.goToBilling')} + +
+ ) : inboundsQuery.data?.length === 0 ? (

{t('configs.noInboundsNotice')}

) : ( inboundsQuery.data && diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 92255f3..eb456fc 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -78,6 +78,8 @@ const resources = { location: 'Локация', label: 'Метка (необязательно)', noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.', + billingRequiredNotice: 'Оплата подписки истекла — создание новых конфигов недоступно, пока не продлите доступ.', + goToBilling: 'Перейти к оплате', created: 'Конфиг создан.', quotaExceeded: 'Достигнут лимит конфигов для вашей роли.', showLink: 'Ссылка / QR', @@ -618,6 +620,8 @@ const resources = { location: 'Location', label: 'Label (optional)', noInboundsNotice: 'No locations are available for creating a config yet. Please contact the administrator — a server needs to be added.', + billingRequiredNotice: 'Your subscription has expired — creating new configs is unavailable until you renew.', + goToBilling: 'Go to payment', created: 'Config created.', quotaExceeded: 'Config quota reached for your role.', showLink: 'Link / QR',