diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs index efa67cb..e5bfbd1 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs @@ -23,6 +23,12 @@ public static class AdminMaintenanceEndpoints admin .MapDelete("/apps/disabled", DeleteDisabledApps) .Produces(); + admin + .MapDelete("/configs/revoked", DeleteRevokedConfigs) + .Produces(); + admin + .MapDelete("/configs/orphaned", DeleteOrphanedConfigs) + .Produces(); admin.MapDelete("/factory-reset", FactoryReset).Produces(StatusCodes.Status204NoContent); return app; @@ -59,6 +65,24 @@ public static class AdminMaintenanceEndpoints return ToResponse(result); } + private static async Task DeleteRevokedConfigs( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteRevokedConfigsCommand(), cancellationToken); + return ToResponse(result); + } + + private static async Task DeleteOrphanedConfigs( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteOrphanedConfigsCommand(), cancellationToken); + return ToResponse(result); + } + private static async Task FactoryReset( ISender sender, CancellationToken cancellationToken diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/ConfigCleanup.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/ConfigCleanup.cs new file mode 100644 index 0000000..8bba671 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/ConfigCleanup.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Admin.Maintenance; + +/// Общее удаление конфигов для команд обслуживания: TrafficSample ссылается на конфиг без +/// FK-каскада, поэтому историю трафика удаляем явно вместе с конфигами. +internal static class ConfigCleanup +{ + public static async Task RemoveWithTrafficAsync( + IAppDbContext dbContext, + IReadOnlyCollection configs, + CancellationToken cancellationToken + ) + { + var configIds = configs.Select(c => c.Id).ToList(); + var samples = await dbContext + .TrafficSamples.Where(s => configIds.Contains(s.ConfigId)) + .ToListAsync(cancellationToken); + + dbContext.TrafficSamples.RemoveRange(samples); + dbContext.VpnConfigs.RemoveRange(configs); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOrphanedConfigsCommand.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOrphanedConfigsCommand.cs new file mode 100644 index 0000000..3e96e0e --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOrphanedConfigsCommand.cs @@ -0,0 +1,9 @@ +using LiteCqrs; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Maintenance; + +/// Удаляет из БД конфиги, чей инбаунд или нода уже удалены (в списке — «? · ?»), в любом +/// статусе, вместе с их историей трафика. Достучаться до 3x-ui для них некуда, поэтому панель не +/// трогаем. Value — число удалённых конфигов. +public sealed record DeleteOrphanedConfigsCommand : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOrphanedConfigsCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOrphanedConfigsCommandHandler.cs new file mode 100644 index 0000000..34a25bf --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteOrphanedConfigsCommandHandler.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using LiteCqrs; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Audit; + +namespace PnvPanel.Application.Admin.Maintenance; + +public sealed class DeleteOrphanedConfigsCommandHandler( + IAppDbContext dbContext, + ICurrentUser currentUser +) : ICommandHandler> +{ + public async Task> Handle( + DeleteOrphanedConfigsCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(AuthErrors.Unauthorized); + + // Инбаунд жив только вместе со своей нодой — «сиротой» считается и конфиг на инбаунде + // удалённой ноды. + var liveInboundIds = dbContext + .Inbounds.Where(i => dbContext.Nodes.Any(n => n.Id == i.NodeId)) + .Select(i => i.Id); + + var configs = await dbContext + .VpnConfigs.Where(c => !liveInboundIds.Contains(c.InboundId)) + .ToListAsync(cancellationToken); + + if (configs.Count == 0) + return Result.Success(0); + + await ConfigCleanup.RemoveWithTrafficAsync(dbContext, configs, cancellationToken); + + dbContext.AuditLogs.Add( + AuditLog.Create( + adminId, + "OrphanedConfigsCleanedUp", + "VpnConfig", + "bulk", + metadata: $"{{\"count\":{configs.Count}}}", + AuditSource.Web + ) + ); + + return Result.Success(configs.Count); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteRevokedConfigsCommand.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteRevokedConfigsCommand.cs new file mode 100644 index 0000000..128c654 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteRevokedConfigsCommand.cs @@ -0,0 +1,9 @@ +using LiteCqrs; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Maintenance; + +/// Удаляет из БД все конфиги в статусе Revoked вместе с их историей трафика. В 3x-ui их +/// клиентов уже нет (отзыв удаляет клиента), поэтому панель не трогаем. Value — число удалённых +/// конфигов. +public sealed record DeleteRevokedConfigsCommand : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteRevokedConfigsCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteRevokedConfigsCommandHandler.cs new file mode 100644 index 0000000..b0c37f3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteRevokedConfigsCommandHandler.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using LiteCqrs; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Audit; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Admin.Maintenance; + +public sealed class DeleteRevokedConfigsCommandHandler( + IAppDbContext dbContext, + ICurrentUser currentUser +) : ICommandHandler> +{ + public async Task> Handle( + DeleteRevokedConfigsCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(AuthErrors.Unauthorized); + + var configs = await dbContext + .VpnConfigs.Where(c => c.Status == ConfigStatus.Revoked) + .ToListAsync(cancellationToken); + + if (configs.Count == 0) + return Result.Success(0); + + await ConfigCleanup.RemoveWithTrafficAsync(dbContext, configs, cancellationToken); + + dbContext.AuditLogs.Add( + AuditLog.Create( + adminId, + "RevokedConfigsCleanedUp", + "VpnConfig", + "bulk", + metadata: $"{{\"count\":{configs.Count}}}", + AuditSource.Web + ) + ); + + return Result.Success(configs.Count); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteConfigsCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteConfigsCommandHandlerTests.cs new file mode 100644 index 0000000..5676045 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteConfigsCommandHandlerTests.cs @@ -0,0 +1,114 @@ +using PnvPanel.Application.Admin.Maintenance; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Maintenance; + +public class DeleteConfigsCommandHandlerTests +{ + private static Node CreateNode() => + Node.Register( + "node-1", + new Uri("https://node-1.example.com"), + new NodeCredentials("admin", "protected"), + null + ); + + private static VpnConfig CreateConfig(Guid inboundId) => + VpnConfig.Create(Guid.NewGuid(), inboundId, VpnProtocol.Vless, null); + + [Fact] + public async Task DeleteRevoked_DeletesRevokedConfigsAndTheirTraffic_KeepsOthers() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = CreateNode(); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + var revoked = CreateConfig(inbound.Id); + revoked.Revoke(); + var active = CreateConfig(inbound.Id); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.AddRange(revoked, active); + dbContext.TrafficSamples.AddRange( + TrafficSample.Create(revoked.Id, DateTimeOffset.UtcNow, 1, 2), + TrafficSample.Create(active.Id, DateTimeOffset.UtcNow, 3, 4) + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new DeleteRevokedConfigsCommandHandler( + dbContext, + FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin") + ); + + var result = await handler.Handle(new DeleteRevokedConfigsCommand(), CancellationToken.None); + // Хендлер не коммитит сам (в проде это делает UnitOfWorkBehavior после диспетчера) — коммитим явно. + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value); + Assert.Equal([active.Id], dbContext.VpnConfigs.Select(c => c.Id).ToList()); + Assert.Equal([active.Id], dbContext.TrafficSamples.Select(s => s.ConfigId).ToList()); + } + + [Fact] + public async Task DeleteOrphaned_DeletesConfigsWithoutInboundOrNode_KeepsLiveOnes() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = CreateNode(); + var liveInbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "live", 443); + // Инбаунд, чья нода удалена. + var orphanInbound = Inbound.FromRemote(Guid.NewGuid(), "2", VpnProtocol.Vless, "orphan", 444); + + var live = CreateConfig(liveInbound.Id); + var onDeletedNode = CreateConfig(orphanInbound.Id); + var onDeletedInbound = CreateConfig(Guid.NewGuid()); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.AddRange(liveInbound, orphanInbound); + dbContext.VpnConfigs.AddRange(live, onDeletedNode, onDeletedInbound); + dbContext.TrafficSamples.Add( + TrafficSample.Create(onDeletedInbound.Id, DateTimeOffset.UtcNow, 1, 2) + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new DeleteOrphanedConfigsCommandHandler( + dbContext, + FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin") + ); + + var result = await handler.Handle(new DeleteOrphanedConfigsCommand(), CancellationToken.None); + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value); + Assert.Equal([live.Id], dbContext.VpnConfigs.Select(c => c.Id).ToList()); + Assert.Empty(dbContext.TrafficSamples); + } + + [Fact] + public async Task DeleteOrphaned_WhenNothingToDelete_ReturnsZero() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = CreateNode(); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "live", 443); + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.Add(CreateConfig(inbound.Id)); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new DeleteOrphanedConfigsCommandHandler( + dbContext, + FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin") + ); + + var result = await handler.Handle(new DeleteOrphanedConfigsCommand(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value); + Assert.Single(dbContext.VpnConfigs); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index 2eeb2e9..cefe2f2 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -241,6 +241,8 @@ TicketAttachmentDto[] }`. | DELETE | `/api/admin/maintenance/tickets/closed` | — | `{ deletedCount }` — удаляет все тикеты в статусе `Closed` вместе с комментариями и вложениями (файлы стираются с диска через `IFileStorage.DeleteAsync`) | | DELETE | `/api/admin/maintenance/audit-logs` | query: `olderThanDays` (1–3650) | `{ deletedCount }` — удаляет записи `AuditLog` старше `olderThanDays` дней | | DELETE | `/api/admin/maintenance/apps/disabled` | — | `{ deletedCount }` — удаляет все `ClientApp` с `IsEnabled = false` | +| DELETE | `/api/admin/maintenance/configs/revoked` | — | `{ deletedCount }` — удаляет из БД все `VpnConfig` в статусе `Revoked` вместе с их `TrafficSample` (3x-ui не трогает — клиентов там уже нет) | +| DELETE | `/api/admin/maintenance/configs/orphaned` | — | `{ deletedCount }` — удаляет из БД `VpnConfig` (в любом статусе), чей `Inbound` или его `Node` уже удалены, вместе с `TrafficSample` | | DELETE | `/api/admin/maintenance/factory-reset` | — | `204 No Content` — полный сброс панели к состоянию свежего деплоя | Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер diff --git a/docs/domain-model.md b/docs/domain-model.md index 7a49fb8..96920a2 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -195,6 +195,10 @@ AppUser ниже), не ролью. См. `Plan` ниже. - Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`). - Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота пользователя). +- Отозванный конфиг (`Revoked`) остаётся в БД, пока админ не почистит его во вкладке «Обслуживание» + (`DELETE /api/admin/maintenance/configs/revoked`). Там же — удаление «сирот»: конфигов, чей + `Inbound` или его `Node` удалены (`DELETE /api/admin/maintenance/configs/orphaned`, в любом статусе). + Обе очистки удаляют и `TrafficSample` этих конфигов, 3x-ui не трогают. > Лимиты трафика не реализованы. `ConfigStatus.LimitReached` в значении enum есть, но код в него > никогда не переводит конфиг. Квота на число конфигов реализована через `AppUser.ConfigQuota` (см. diff --git a/frontend/src/features/admin/maintenance/api.ts b/frontend/src/features/admin/maintenance/api.ts index c6622f2..c960033 100644 --- a/frontend/src/features/admin/maintenance/api.ts +++ b/frontend/src/features/admin/maintenance/api.ts @@ -15,6 +15,14 @@ export function deleteDisabledApps() { return apiRequest('/admin/maintenance/apps/disabled', { method: 'DELETE' }) } +export function deleteRevokedConfigs() { + return apiRequest('/admin/maintenance/configs/revoked', { method: 'DELETE' }) +} + +export function deleteOrphanedConfigs() { + return apiRequest('/admin/maintenance/configs/orphaned', { method: 'DELETE' }) +} + export function factoryReset() { return apiRequest('/admin/maintenance/factory-reset', { method: 'DELETE' }) } diff --git a/frontend/src/routes/admin/maintenance.tsx b/frontend/src/routes/admin/maintenance.tsx index 7dfd711..0373e4d 100644 --- a/frontend/src/routes/admin/maintenance.tsx +++ b/frontend/src/routes/admin/maintenance.tsx @@ -6,7 +6,13 @@ import { toast } from '@/shared/ui/toast-store' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' -import { deleteClosedTickets, deleteDisabledApps, deleteOldAuditLogs } from '@/features/admin/maintenance/api' +import { + deleteClosedTickets, + deleteDisabledApps, + deleteOldAuditLogs, + deleteOrphanedConfigs, + deleteRevokedConfigs, +} from '@/features/admin/maintenance/api' import { FactoryResetDialog } from '@/features/admin/maintenance/FactoryResetDialog' export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage }) @@ -45,6 +51,23 @@ function AdminMaintenancePage() { onError: () => toast.error(t('auth.genericError')), }) + const onConfigsDeleted = async (data: { deletedCount: number }) => { + toast.success(t('admin.maintenance.configs.deleted', { count: data.deletedCount })) + await queryClient.invalidateQueries({ queryKey: ['admin-configs'] }) + } + + const deleteRevokedConfigsMutation = useMutation({ + mutationFn: deleteRevokedConfigs, + onSuccess: onConfigsDeleted, + onError: () => toast.error(t('auth.genericError')), + }) + + const deleteOrphanedConfigsMutation = useMutation({ + mutationFn: deleteOrphanedConfigs, + onSuccess: onConfigsDeleted, + onError: () => toast.error(t('auth.genericError')), + }) + return (
@@ -120,6 +143,35 @@ function AdminMaintenancePage() { + + + {t('admin.maintenance.configs.title')} + + +

{t('admin.maintenance.configs.description')}

+
+ + +
+
+
+
{t('admin.maintenance.factoryReset.dangerZone')} diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index c9c5707..f137ed3 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -586,6 +586,17 @@ const resources = { confirm: 'Удалить все отключённые приложения? Действие необратимо.', deleted: 'Удалено приложений: {{count}}.', }, + configs: { + title: 'Конфиги', + description: + 'Удалить из базы отозванные конфиги или конфиги, чей инбаунд/нода уже удалены (в списке — «? · ?»). Вместе с конфигами удаляется история трафика. Действие необратимо.', + revokedAction: 'Удалить все отозванные конфиги', + revokedConfirm: 'Удалить все отозванные конфиги? Действие необратимо.', + orphanedAction: 'Удалить все конфиги без ноды', + orphanedConfirm: + 'Удалить все конфиги, чей инбаунд или нода удалены, в любом статусе? Действие необратимо.', + deleted: 'Удалено конфигов: {{count}}.', + }, factoryReset: { dangerZone: 'Опасная зона', title: 'Полный сброс панели', @@ -1205,6 +1216,17 @@ const resources = { confirm: 'Delete all disabled apps? This cannot be undone.', deleted: 'Deleted apps: {{count}}.', }, + configs: { + title: 'Configs', + description: + 'Delete revoked configs, or configs whose inbound/node no longer exists (shown as "? · ?"), from the database along with their traffic history. This cannot be undone.', + revokedAction: 'Delete all revoked configs', + revokedConfirm: 'Delete all revoked configs? This cannot be undone.', + orphanedAction: 'Delete all configs without a node', + orphanedConfirm: + 'Delete all configs whose inbound or node was deleted, in any status? This cannot be undone.', + deleted: 'Deleted configs: {{count}}.', + }, factoryReset: { dangerZone: 'Danger zone', title: 'Full panel reset',