diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props
index 15e774b..e4c694a 100644
--- a/backend/Directory.Packages.props
+++ b/backend/Directory.Packages.props
@@ -24,6 +24,17 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/PnvPanel.slnx b/backend/PnvPanel.slnx
index 811000e..92128e6 100644
--- a/backend/PnvPanel.slnx
+++ b/backend/PnvPanel.slnx
@@ -5,4 +5,9 @@
+
+
+
+
+
diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs
new file mode 100644
index 0000000..c7c9855
--- /dev/null
+++ b/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs
@@ -0,0 +1,54 @@
+using PnvPanel.Api.Common;
+using PnvPanel.Application.Admin.Apps;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Domain.Apps;
+using PnvPanel.Infrastructure.Identity;
+
+namespace PnvPanel.Api.Endpoints;
+
+public static class AdminAppEndpoints
+{
+ public static IEndpointRouteBuilder MapAdminAppEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin/apps")
+ .WithTags("Admin.Apps")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapGet("", ListApps);
+ admin.MapPost("", CreateApp);
+ admin.MapPut("/{id:guid}", UpdateApp);
+ admin.MapDelete("/{id:guid}", DeleteApp);
+
+ return app;
+ }
+
+ private static async Task ListApps(ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new ListAdminAppsQuery(), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task CreateApp(CreateAppCommand command, ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(command, cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task UpdateApp(Guid id, UpdateAppBody body, ISender sender, CancellationToken cancellationToken)
+ {
+ var command = new UpdateAppCommand(
+ id, body.Name, body.DownloadUrl, body.OperatingSystem, body.Description, body.IconUrl, body.SortOrder, body.IsEnabled);
+ var result = await sender.Send(command, cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task DeleteApp(Guid id, ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new DeleteAppCommand(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+}
+
+public sealed record UpdateAppBody(
+ string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl,
+ int SortOrder, bool IsEnabled);
diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs
new file mode 100644
index 0000000..5b7fd67
--- /dev/null
+++ b/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs
@@ -0,0 +1,35 @@
+using PnvPanel.Api.Common;
+using PnvPanel.Application.Admin.Audit;
+using PnvPanel.Application.Admin.Stats;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Infrastructure.Identity;
+
+namespace PnvPanel.Api.Endpoints;
+
+public static class AdminStatsEndpoints
+{
+ public static IEndpointRouteBuilder MapAdminStatsEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin")
+ .WithTags("Admin.Stats")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapGet("/stats", GetStats);
+ admin.MapGet("/audit", GetAudit);
+
+ return app;
+ }
+
+ private static async Task GetStats(ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new GetStatsQuery(), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task GetAudit(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
+ {
+ var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize);
+ var result = await sender.Send(query, cancellationToken);
+ return result.ToHttpResult();
+ }
+}
diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs
new file mode 100644
index 0000000..1555c2f
--- /dev/null
+++ b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs
@@ -0,0 +1,65 @@
+using PnvPanel.Api.Common;
+using PnvPanel.Application.Admin.Users;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Infrastructure.Identity;
+
+namespace PnvPanel.Api.Endpoints;
+
+public static class AdminUserEndpoints
+{
+ public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin")
+ .WithTags("Admin.Users")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapGet("/users", ListUsers);
+ admin.MapPatch("/users/{id:guid}/block", BlockUser);
+ admin.MapPatch("/users/{id:guid}/unblock", UnblockUser);
+ admin.MapPost("/users/{id:guid}/reset-password", ResetPassword);
+ admin.MapGet("/users/{id:guid}/configs", GetUserConfigs);
+ admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig);
+
+ return app;
+ }
+
+ private static async Task ListUsers(
+ int page, int pageSize, string? search, ISender sender, CancellationToken cancellationToken)
+ {
+ var query = new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search);
+ var result = await sender.Send(query, cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task BlockUser(Guid id, ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task UnblockUser(Guid id, ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task ResetPassword(Guid id, ResetPasswordBody body, ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new ResetUserPasswordCommand(id, body.NewPassword), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task ForceRevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+}
+
+public sealed record ResetPasswordBody(string NewPassword);
diff --git a/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs
new file mode 100644
index 0000000..af629c1
--- /dev/null
+++ b/backend/src/PnvPanel.Api/Endpoints/TelegramEndpoints.cs
@@ -0,0 +1,91 @@
+using Microsoft.Extensions.Options;
+using PnvPanel.Api.Common;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Telegram;
+using PnvPanel.Infrastructure.Telegram;
+
+namespace PnvPanel.Api.Endpoints;
+
+public static class TelegramEndpoints
+{
+ public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/auth/telegram")
+ .WithTags("Auth.Telegram")
+ .RequireRateLimiting(RateLimiting.AuthPolicy);
+
+ group.MapPost("/link-token", CreateLinkToken).RequireAuthorization();
+ group.MapPost("/unlink", Unlink).RequireAuthorization();
+ group.MapPost("/login-request", CreateLoginRequest);
+ group.MapGet("/login-request/{id:guid}", GetLoginRequestStatus);
+
+ return app;
+ }
+
+ private static async Task CreateLinkToken(
+ ISender sender, IOptions options, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken);
+ if (!result.IsSuccess)
+ return result.ToHttpResult();
+
+ var botUsername = options.Value.BotUsername;
+ var deepLink = string.IsNullOrWhiteSpace(botUsername)
+ ? null
+ : $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
+
+ return Results.Ok(new { deepLink, expiresAt = result.Value.ExpiresAt });
+ }
+
+ private static async Task Unlink(ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task CreateLoginRequest(
+ HttpRequest request, ISender sender, IOptions options, CancellationToken cancellationToken)
+ {
+ var context = request.HttpContext.Connection.RemoteIpAddress?.ToString();
+ var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken);
+ if (!result.IsSuccess)
+ return result.ToHttpResult();
+
+ var botUsername = options.Value.BotUsername;
+ var deepLink = string.IsNullOrWhiteSpace(botUsername)
+ ? null
+ : $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}";
+
+ return Results.Ok(new { requestId = result.Value.RequestId, deepLink, expiresAt = result.Value.ExpiresAt });
+ }
+
+ private static async Task GetLoginRequestStatus(Guid id, HttpResponse response, ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken);
+ if (!result.IsSuccess)
+ return result.ToHttpResult();
+
+ if (result.Value.Auth is { } auth)
+ {
+ var cookieOptions = new CookieOptions
+ {
+ HttpOnly = true,
+ Secure = true,
+ SameSite = SameSiteMode.Strict,
+ Path = "/api/auth",
+ Expires = auth.RefreshTokenExpiresAt,
+ };
+ response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions);
+
+ return Results.Ok(new
+ {
+ status = result.Value.Status.ToString(),
+ accessToken = auth.AccessToken,
+ expiresAt = auth.AccessTokenExpiresAt,
+ user = auth.User,
+ });
+ }
+
+ return Results.Ok(new { status = result.Value.Status.ToString() });
+ }
+}
diff --git a/backend/src/PnvPanel.Api/Hubs/PanelHub.cs b/backend/src/PnvPanel.Api/Hubs/PanelHub.cs
new file mode 100644
index 0000000..d6b9bcd
--- /dev/null
+++ b/backend/src/PnvPanel.Api/Hubs/PanelHub.cs
@@ -0,0 +1,33 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.SignalR;
+using PnvPanel.Infrastructure.Identity;
+
+namespace PnvPanel.Api.Hubs;
+
+///
+/// Группы: user:{userId} (личные события — трафик/статус конфига), admins (статусы нод,
+/// запросы активации). UserIdentifier берётся из claim NameIdentifier — того же, что кладём в JWT.
+///
+[Authorize]
+public sealed class PanelHub : Hub
+{
+ public override async Task OnConnectedAsync()
+ {
+ if (Context.UserIdentifier is { } userId)
+ await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.User(userId));
+
+ if (Context.User?.IsInRole(RoleNames.Admin) == true)
+ await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.Admins);
+
+ await base.OnConnectedAsync();
+ }
+}
+
+public static class GroupNames
+{
+ public const string Admins = "admins";
+
+ public static string User(string userId) => $"user:{userId}";
+
+ public static string User(Guid userId) => User(userId.ToString());
+}
diff --git a/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs b/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs
new file mode 100644
index 0000000..876244e
--- /dev/null
+++ b/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs
@@ -0,0 +1,51 @@
+using Microsoft.AspNetCore.SignalR;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Domain.Configs;
+using PnvPanel.Domain.Nodes;
+
+namespace PnvPanel.Api.Hubs;
+
+internal sealed class SignalRRealtimeNotifier(IHubContext hubContext) : IRealtimeNotifier
+{
+ public Task NotifyConfigTrafficUpdatedAsync(
+ Guid userId, Guid configId, long usedUpBytes, long usedDownBytes, CancellationToken cancellationToken)
+ {
+ return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
+ "configTrafficUpdated",
+ new { configId, usedUpBytes, usedDownBytes },
+ cancellationToken);
+ }
+
+ public Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken)
+ {
+ return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
+ "configStatusChanged",
+ new { configId, status = status.ToString() },
+ cancellationToken);
+ }
+
+ public Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken)
+ {
+ return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
+ "nodeStatusChanged",
+ new { nodeId, status = status.ToString(), lastSyncAt },
+ cancellationToken);
+ }
+
+ public Task NotifyActivationRequestedAsync(
+ Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken)
+ {
+ return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
+ "activationRequested",
+ new { requestId, userId, userName, comment, createdAt },
+ cancellationToken);
+ }
+
+ public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
+ "userActivated",
+ new { userId },
+ cancellationToken);
+ }
+}
diff --git a/backend/src/PnvPanel.Api/PnvPanel.Api.csproj b/backend/src/PnvPanel.Api/PnvPanel.Api.csproj
index e0fcc93..c00562a 100644
--- a/backend/src/PnvPanel.Api/PnvPanel.Api.csproj
+++ b/backend/src/PnvPanel.Api/PnvPanel.Api.csproj
@@ -19,6 +19,7 @@
+
diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs
index 87917b1..f89a532 100644
--- a/backend/src/PnvPanel.Api/Program.cs
+++ b/backend/src/PnvPanel.Api/Program.cs
@@ -1,13 +1,19 @@
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.Extensions.Options;
using PnvPanel.Api.Common;
using PnvPanel.Api.Endpoints;
+using PnvPanel.Api.Hubs;
+using PnvPanel.Api.Telegram;
using PnvPanel.Application;
+using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Infrastructure;
using PnvPanel.Infrastructure.Identity;
using PnvPanel.Infrastructure.Persistence;
+using PnvPanel.Infrastructure.Telegram;
using Scalar.AspNetCore;
using Serilog;
+using Telegram.Bot;
var builder = WebApplication.CreateBuilder(args);
@@ -29,6 +35,22 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
+builder.Services.AddSignalR();
+// В Api, не в Infrastructure — реализации нужен IHubContext, а Hub определён здесь же.
+builder.Services.AddSingleton();
+
+// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
+// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена.
+builder.Services.AddSingleton(sp =>
+{
+ var options = sp.GetRequiredService>();
+ return new TelegramBotClient(options.Value.BotToken ?? string.Empty);
+});
+// Scoped — зависит от IIdentityService (scoped), не Singleton.
+builder.Services.AddScoped();
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
@@ -74,6 +96,12 @@ app.MapInboundEndpoints();
app.MapConfigEndpoints();
app.MapSubscriptionEndpoints();
app.MapAppEndpoints();
+app.MapAdminUserEndpoints();
+app.MapAdminStatsEndpoints();
+app.MapAdminAppEndpoints();
+app.MapTelegramEndpoints();
+
+app.MapHub("/hubs/panel");
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -81,3 +109,6 @@ app.UseStaticFiles();
app.MapFallbackToFile("index.html");
app.Run();
+
+/// Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах.
+public partial class Program;
diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs
new file mode 100644
index 0000000..b5d4997
--- /dev/null
+++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs
@@ -0,0 +1,293 @@
+using Microsoft.Extensions.Options;
+using PnvPanel.Application.Admin.Activation;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Configs.GetMyConfigs;
+using PnvPanel.Application.Telegram;
+using PnvPanel.Application.Telegram.Bot;
+using PnvPanel.Domain.Activation;
+using PnvPanel.Infrastructure.Telegram;
+using Telegram.Bot;
+using Telegram.Bot.Polling;
+using Telegram.Bot.Types;
+using Telegram.Bot.Types.Enums;
+using Telegram.Bot.Types.ReplyMarkups;
+
+namespace PnvPanel.Api.Telegram;
+
+///
+/// Обрабатывает апдейты бота. Каждый апдейт — свой DI-scope (как HTTP-запрос), чтобы получить
+/// свежие scoped-сервисы (ISender, ICurrentUserSetter, ...). Бот — read-only по конфигам в MVP.
+///
+public sealed class PnvBotUpdateHandler(
+ IServiceScopeFactory scopeFactory, IOptions options, ILogger logger)
+ : IUpdateHandler
+{
+ public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+
+ try
+ {
+ if (update.Message is { Text: { } text } message)
+ await HandleMessageAsync(botClient, scope.ServiceProvider, message, text, cancellationToken);
+ else if (update.CallbackQuery is { } callback)
+ await HandleCallbackAsync(botClient, scope.ServiceProvider, callback, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Ошибка обработки Telegram-апдейта {UpdateId}", update.Id);
+ }
+ }
+
+ public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken)
+ {
+ logger.LogError(exception, "Ошибка Telegram-бота (источник {Source})", source);
+ return Task.CompletedTask;
+ }
+
+ private async Task HandleMessageAsync(
+ ITelegramBotClient botClient, IServiceProvider services, Message message, string text, CancellationToken cancellationToken)
+ {
+ var chatId = message.Chat.Id;
+ var fromId = message.From?.Id;
+ if (fromId is null)
+ return;
+
+ if (text.StartsWith("/start", StringComparison.Ordinal))
+ {
+ var payload = text.Length > 7 ? text[7..].Trim() : string.Empty;
+
+ if (payload.StartsWith("link_", StringComparison.Ordinal))
+ await HandleLinkAsync(botClient, services, chatId, fromId.Value, message.From?.Username, payload[5..], cancellationToken);
+ else if (payload.StartsWith("login_", StringComparison.Ordinal))
+ await HandleLoginPromptAsync(botClient, services, chatId, fromId.Value, payload[6..], cancellationToken);
+ else
+ await SendWelcomeAsync(botClient, chatId, cancellationToken);
+
+ return;
+ }
+
+ switch (text)
+ {
+ case "/configs":
+ await HandleConfigsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
+ break;
+ case "/unlink":
+ await HandleUnlinkAsync(botClient, services, chatId, fromId.Value, cancellationToken);
+ break;
+ case "/requests":
+ await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
+ break;
+ case "/help":
+ await SendWelcomeAsync(botClient, chatId, cancellationToken);
+ break;
+ default:
+ await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken);
+ break;
+ }
+ }
+
+ private async Task HandleCallbackAsync(
+ ITelegramBotClient botClient, IServiceProvider services, CallbackQuery callback, CancellationToken cancellationToken)
+ {
+ var data = callback.Data;
+ var chatId = callback.Message?.Chat.Id;
+ var fromId = callback.From.Id;
+ if (data is null || chatId is null)
+ return;
+
+ var parts = data.Split(':');
+ if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId))
+ return;
+
+ var sender = services.GetRequiredService();
+
+ switch (parts[0])
+ {
+ case "login":
+ {
+ var result = parts[1] == "approve"
+ ? await sender.Send(new ApproveTelegramLoginCommand(requestId, fromId), cancellationToken)
+ : await sender.Send(new RejectTelegramLoginCommand(requestId, fromId), cancellationToken);
+
+ await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
+ if (result.IsSuccess)
+ {
+ var text = parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.";
+ await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken);
+ }
+
+ break;
+ }
+ case "act":
+ {
+ if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
+ {
+ await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken);
+ return;
+ }
+
+ var result = parts[1] == "approve"
+ ? await sender.Send(new ApproveActivationCommand(requestId), cancellationToken)
+ : await sender.Send(new RejectActivationCommand(requestId, Reason: null), cancellationToken);
+
+ await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
+ if (result.IsSuccess)
+ {
+ var text = parts[1] == "approve" ? "✅ Пользователь активирован." : "❌ Запрос отклонён.";
+ await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken);
+ }
+
+ break;
+ }
+ }
+ }
+
+ private async Task HandleLinkAsync(
+ ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string token,
+ CancellationToken cancellationToken)
+ {
+ var sender = services.GetRequiredService();
+ var result = await sender.Send(new LinkTelegramCommand(token, fromId, username), cancellationToken);
+
+ var text = result.IsSuccess
+ ? "✅ Telegram успешно привязан к вашему аккаунту."
+ : $"❌ Не удалось привязать: {result.Error.Message}";
+
+ await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
+ }
+
+ private async Task HandleLoginPromptAsync(
+ ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string requestIdRaw, CancellationToken cancellationToken)
+ {
+ if (!Guid.TryParse(requestIdRaw, out var requestId))
+ {
+ await botClient.SendMessage(chatId, "Некорректная ссылка входа.", cancellationToken: cancellationToken);
+ return;
+ }
+
+ var identityService = services.GetRequiredService();
+ var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
+ if (userId is null)
+ {
+ await botClient.SendMessage(chatId, "Сначала привяжите Telegram к аккаунту на сайте.", cancellationToken: cancellationToken);
+ return;
+ }
+
+ var keyboard = new InlineKeyboardMarkup(new[]
+ {
+ InlineKeyboardButton.WithCallbackData("✅ Подтвердить вход", $"login:approve:{requestId}"),
+ InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
+ });
+
+ await botClient.SendMessage(
+ chatId, "Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
+ replyMarkup: keyboard, cancellationToken: cancellationToken);
+ }
+
+ private async Task HandleConfigsAsync(
+ ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
+ {
+ if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
+ {
+ await botClient.SendMessage(
+ chatId, "Сначала привяжите Telegram к аккаунту на сайте (Настройки → Привязать Telegram).",
+ cancellationToken: cancellationToken);
+ return;
+ }
+
+ var sender = services.GetRequiredService();
+ var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
+
+ if (!result.IsSuccess || result.Value.Configs.Count == 0)
+ {
+ await botClient.SendMessage(chatId, "У вас пока нет конфигов.", cancellationToken: cancellationToken);
+ return;
+ }
+
+ var lines = result.Value.Configs.Select(c =>
+ $"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}");
+ await botClient.SendMessage(chatId, "Ваши конфиги:\n" + string.Join('\n', lines), cancellationToken: cancellationToken);
+ }
+
+ private async Task HandleUnlinkAsync(
+ ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
+ {
+ if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
+ {
+ await botClient.SendMessage(chatId, "Telegram не привязан.", cancellationToken: cancellationToken);
+ return;
+ }
+
+ var sender = services.GetRequiredService();
+ var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
+
+ await botClient.SendMessage(
+ chatId, result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
+ cancellationToken: cancellationToken);
+ }
+
+ private async Task HandleRequestsAsync(
+ ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
+ {
+ if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
+ {
+ await botClient.SendMessage(chatId, "Недостаточно прав.", cancellationToken: cancellationToken);
+ return;
+ }
+
+ var sender = services.GetRequiredService();
+ var result = await sender.Send(new ListActivationRequestsQuery(ActivationStatus.Pending, 1, 10), cancellationToken);
+
+ if (!result.IsSuccess || result.Value.Items.Count == 0)
+ {
+ await botClient.SendMessage(chatId, "Нет ожидающих запросов на активацию.", cancellationToken: cancellationToken);
+ return;
+ }
+
+ foreach (var item in result.Value.Items)
+ {
+ var text = $"Запрос от {item.UserName}" + (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}");
+ var keyboard = new InlineKeyboardMarkup(new[]
+ {
+ InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{item.Id}"),
+ InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{item.Id}"),
+ });
+ await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
+ }
+ }
+
+ private static async Task SendWelcomeAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
+ {
+ const string text = "Привет! Это бот PnvPanel.\n\n"
+ + "/configs — мои конфиги\n"
+ + "/login — войти на сайт без пароля\n"
+ + "/unlink — отвязать Telegram\n"
+ + "/help — эта справка";
+ await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
+ }
+
+ private static async Task TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
+ {
+ var identityService = services.GetRequiredService();
+ var userId = await identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, cancellationToken);
+ if (userId is null)
+ return false;
+
+ var profile = await identityService.GetProfileAsync(userId.Value, cancellationToken);
+ if (profile is null)
+ return false;
+
+ services.GetRequiredService().SetUser(profile.Id, profile.UserName);
+ return true;
+ }
+
+ private async Task TrySetAdminCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
+ {
+ if (!options.Value.ParseAdminTelegramUserIds().Contains(telegramUserId))
+ return false;
+
+ return await TrySetCurrentUserAsync(services, telegramUserId, cancellationToken);
+ }
+}
diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramBotHostedService.cs b/backend/src/PnvPanel.Api/Telegram/TelegramBotHostedService.cs
new file mode 100644
index 0000000..18499b8
--- /dev/null
+++ b/backend/src/PnvPanel.Api/Telegram/TelegramBotHostedService.cs
@@ -0,0 +1,45 @@
+using Microsoft.Extensions.Options;
+using PnvPanel.Infrastructure.Telegram;
+using Telegram.Bot;
+using Telegram.Bot.Polling;
+using Telegram.Bot.Types;
+using Telegram.Bot.Types.Enums;
+
+namespace PnvPanel.Api.Telegram;
+
+///
+/// Бот — presentation-адаптер, хостится в процессе Api (long polling). Если BotToken не задан,
+/// не стартует — панель работает без бота. Апдейты обрабатывает PnvBotUpdateHandler, который
+/// вызывает те же CQRS-команды, что и веб, через собственный ISender.
+///
+public sealed class TelegramBotHostedService(
+ ITelegramBotClient botClient, PnvBotUpdateHandler updateHandler, IOptions options,
+ ILogger logger)
+ : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ if (string.IsNullOrWhiteSpace(options.Value.BotToken))
+ {
+ logger.LogWarning("Telegram__BotToken не задан — бот не стартует.");
+ return;
+ }
+
+ var receiverOptions = new ReceiverOptions
+ {
+ AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery],
+ DropPendingUpdates = true,
+ };
+
+ logger.LogInformation("Telegram-бот запускается (long polling)...");
+
+ try
+ {
+ await botClient.ReceiveAsync(updateHandler, receiverOptions, stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ // Штатная остановка вместе с приложением.
+ }
+ }
+}
diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs
new file mode 100644
index 0000000..a33b1cb
--- /dev/null
+++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.Options;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Infrastructure.Telegram;
+using Telegram.Bot;
+using Telegram.Bot.Types.Enums;
+using Telegram.Bot.Types.ReplyMarkups;
+
+namespace PnvPanel.Api.Telegram;
+
+internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentityService identityService, IOptions options)
+ : ITelegramNotifier
+{
+ public async Task NotifyAdminsActivationRequestedAsync(
+ Guid requestId, string userName, string? comment, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(options.Value.BotToken))
+ return;
+
+ var text = $"🆕 Запрос на активацию от {Escape(userName)}"
+ + (string.IsNullOrWhiteSpace(comment) ? string.Empty : $"\nКомментарий: {Escape(comment)}");
+
+ var keyboard = new InlineKeyboardMarkup(new[]
+ {
+ InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
+ InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
+ });
+
+ foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
+ {
+ try
+ {
+ await botClient.SendMessage(
+ adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
+ }
+ catch
+ {
+ // Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
+ }
+ }
+ }
+
+ public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(options.Value.BotToken))
+ return;
+
+ var link = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
+ if (!link.IsLinked || link.TelegramUserId is not { } telegramUserId)
+ return;
+
+ try
+ {
+ await botClient.SendMessage(telegramUserId, message, cancellationToken: cancellationToken);
+ }
+ catch
+ {
+ // Пользователь мог заблокировать бота — не критично для основной операции.
+ }
+ }
+
+ private static string Escape(string text) => text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
+}
diff --git a/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs
index ebc960c..200ec3d 100644
--- a/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs
@@ -7,7 +7,8 @@ using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Activation;
-public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
+public sealed class RequestActivationCommandHandler(
+ IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler>
{
public async Task> Handle(RequestActivationCommand command, CancellationToken cancellationToken)
@@ -24,6 +25,11 @@ public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICu
var request = ActivationRequest.Create(userId, command.Comment);
dbContext.ActivationRequests.Add(request);
+ var userName = currentUser.UserName ?? userId.ToString();
+
+ await notifier.NotifyActivationRequestedAsync(request.Id, userId, userName, request.Comment, request.CreatedAt, cancellationToken);
+ await telegramNotifier.NotifyAdminsActivationRequestedAsync(request.Id, userName, request.Comment, cancellationToken);
+
return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt));
}
}
diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs
index 1cbbffd..3535332 100644
--- a/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs
@@ -8,7 +8,8 @@ using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Admin.Activation;
-public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
+public sealed class ApproveActivationCommandHandler(
+ IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier, ICurrentUser currentUser)
: ICommandHandler
{
public async Task Handle(ApproveActivationCommand command, CancellationToken cancellationToken)
@@ -27,6 +28,11 @@ public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IId
request.Approve(adminId);
- return await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken);
+ var activateResult = await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken);
+ if (!activateResult.IsSuccess)
+ return activateResult;
+
+ await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
+ return Result.Success();
}
}
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/AdminAppDto.cs b/backend/src/PnvPanel.Application/Admin/Apps/AdminAppDto.cs
new file mode 100644
index 0000000..e430043
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/AdminAppDto.cs
@@ -0,0 +1,12 @@
+using PnvPanel.Domain.Apps;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed record AdminAppDto(
+ Guid Id, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
+ string? IconUrl, int SortOrder, bool IsEnabled)
+{
+ public static AdminAppDto FromDomain(ClientApp app) => new(
+ app.Id, app.Name, app.DownloadUrl.ToString(), app.OperatingSystem, app.Description,
+ app.IconUrl, app.SortOrder, app.IsEnabled);
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/AppErrors.cs b/backend/src/PnvPanel.Application/Admin/Apps/AppErrors.cs
new file mode 100644
index 0000000..7e16574
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/AppErrors.cs
@@ -0,0 +1,8 @@
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public static class AppErrors
+{
+ public static readonly Error NotFound = Error.NotFound("Apps.NotFound", "Приложение не найдено.");
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommand.cs b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommand.cs
new file mode 100644
index 0000000..bb55ff9
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommand.cs
@@ -0,0 +1,9 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Apps;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed record CreateAppCommand(
+ string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl, int SortOrder)
+ : ICommand>;
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandHandler.cs
new file mode 100644
index 0000000..434fbae
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandHandler.cs
@@ -0,0 +1,20 @@
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Apps;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed class CreateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler>
+{
+ public Task> Handle(CreateAppCommand command, CancellationToken cancellationToken)
+ {
+ var app = ClientApp.Create(
+ command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
+ command.Description, command.IconUrl, command.SortOrder);
+
+ dbContext.ClientApps.Add(app);
+
+ return Task.FromResult(Result.Success(AdminAppDto.FromDomain(app)));
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandValidator.cs
new file mode 100644
index 0000000..3b4f1ef
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandValidator.cs
@@ -0,0 +1,15 @@
+using FluentValidation;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed class CreateAppCommandValidator : AbstractValidator
+{
+ public CreateAppCommandValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
+ RuleFor(x => x.DownloadUrl).NotEmpty().MaximumLength(500);
+ RuleFor(x => x.OperatingSystem).IsInEnum();
+ RuleFor(x => x.Description).MaximumLength(300);
+ RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommand.cs b/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommand.cs
new file mode 100644
index 0000000..2d68d6c
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommand.cs
@@ -0,0 +1,6 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed record DeleteAppCommand(Guid AppId) : ICommand;
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommandHandler.cs
new file mode 100644
index 0000000..2d6d1a1
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/DeleteAppCommandHandler.cs
@@ -0,0 +1,19 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed class DeleteAppCommandHandler(IAppDbContext dbContext) : ICommandHandler
+{
+ public async Task Handle(DeleteAppCommand command, CancellationToken cancellationToken)
+ {
+ var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken);
+ if (app is null)
+ return Result.Failure(AppErrors.NotFound);
+
+ dbContext.ClientApps.Remove(app);
+ return Result.Success();
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQuery.cs b/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQuery.cs
new file mode 100644
index 0000000..19c4e83
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQuery.cs
@@ -0,0 +1,6 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed record ListAdminAppsQuery : IQuery>>;
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQueryHandler.cs
new file mode 100644
index 0000000..95b390f
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/ListAdminAppsQueryHandler.cs
@@ -0,0 +1,18 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext) : IQueryHandler>>
+{
+ public async Task>> Handle(ListAdminAppsQuery query, CancellationToken cancellationToken)
+ {
+ var apps = await dbContext.ClientApps.AsNoTracking()
+ .OrderBy(a => a.OperatingSystem).ThenBy(a => a.SortOrder)
+ .ToListAsync(cancellationToken);
+
+ return Result.Success>(apps.Select(AdminAppDto.FromDomain).ToList());
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommand.cs b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommand.cs
new file mode 100644
index 0000000..cf5d089
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommand.cs
@@ -0,0 +1,10 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Apps;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed record UpdateAppCommand(
+ Guid AppId, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
+ string? IconUrl, int SortOrder, bool IsEnabled)
+ : ICommand>;
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandHandler.cs
new file mode 100644
index 0000000..2ad1f2b
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandHandler.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed class UpdateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler>
+{
+ public async Task> Handle(UpdateAppCommand command, CancellationToken cancellationToken)
+ {
+ var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken);
+ if (app is null)
+ return Result.Failure(AppErrors.NotFound);
+
+ app.Update(
+ command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
+ command.Description, command.IconUrl, command.SortOrder, command.IsEnabled);
+
+ return Result.Success(AdminAppDto.FromDomain(app));
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandValidator.cs
new file mode 100644
index 0000000..9856890
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandValidator.cs
@@ -0,0 +1,15 @@
+using FluentValidation;
+
+namespace PnvPanel.Application.Admin.Apps;
+
+public sealed class UpdateAppCommandValidator : AbstractValidator
+{
+ public UpdateAppCommandValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
+ RuleFor(x => x.DownloadUrl).NotEmpty().MaximumLength(500);
+ RuleFor(x => x.OperatingSystem).IsInEnum();
+ RuleFor(x => x.Description).MaximumLength(300);
+ RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQuery.cs b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQuery.cs
new file mode 100644
index 0000000..e33a781
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQuery.cs
@@ -0,0 +1,11 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Audit;
+
+namespace PnvPanel.Application.Admin.Audit;
+
+public sealed record ListAuditLogsQuery(int Page, int PageSize) : IQuery>>;
+
+public sealed record AuditLogDto(
+ long Id, Guid? ActorId, string Action, string TargetType, string TargetId, string? Metadata,
+ AuditSource Source, DateTimeOffset CreatedAt);
diff --git a/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQueryHandler.cs
new file mode 100644
index 0000000..27aca6e
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQueryHandler.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Audit;
+
+public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext) : IQueryHandler>>
+{
+ public async Task>> Handle(ListAuditLogsQuery query, CancellationToken cancellationToken)
+ {
+ var page = query.Page <= 0 ? 1 : query.Page;
+ var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize;
+
+ var result = await dbContext.AuditLogs.AsNoTracking()
+ .OrderByDescending(a => a.CreatedAt)
+ .Select(a => new AuditLogDto(a.Id, a.ActorId, a.Action, a.TargetType, a.TargetId, a.Metadata, a.Source, a.CreatedAt))
+ .ToPagedListAsync(page, pageSize, cancellationToken);
+
+ return Result.Success(result);
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQuery.cs b/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQuery.cs
new file mode 100644
index 0000000..3cd6b5e
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQuery.cs
@@ -0,0 +1,11 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Stats;
+
+public sealed record GetStatsQuery : IQuery>;
+
+public sealed record StatsDto(
+ int TotalUsers, int ActivatedUsers, int PendingActivationRequests,
+ int TotalNodes, int OnlineNodes, int TotalConfigs, int ActiveConfigs,
+ long TotalUsedUpBytes, long TotalUsedDownBytes);
diff --git a/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQueryHandler.cs
new file mode 100644
index 0000000..e881f30
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Stats/GetStatsQueryHandler.cs
@@ -0,0 +1,37 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Activation;
+using PnvPanel.Domain.Configs;
+using PnvPanel.Domain.Nodes;
+
+namespace PnvPanel.Application.Admin.Stats;
+
+public sealed class GetStatsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
+ : IQueryHandler>
+{
+ public async Task> Handle(GetStatsQuery query, CancellationToken cancellationToken)
+ {
+ var userStats = await identityService.GetUserStatsAsync(cancellationToken);
+
+ var pendingActivations = await dbContext.ActivationRequests
+ .CountAsync(r => r.Status == ActivationStatus.Pending, cancellationToken);
+
+ var totalNodes = await dbContext.Nodes.CountAsync(cancellationToken);
+ var onlineNodes = await dbContext.Nodes.CountAsync(n => n.Status == NodeStatus.Online, cancellationToken);
+
+ var totalConfigs = await dbContext.VpnConfigs.CountAsync(cancellationToken);
+ var activeConfigs = await dbContext.VpnConfigs.CountAsync(c => c.Status == ConfigStatus.Active, cancellationToken);
+
+ var trafficTotals = await dbContext.VpnConfigs
+ .GroupBy(_ => 1)
+ .Select(g => new { Up = g.Sum(c => c.UsedUpBytes), Down = g.Sum(c => c.UsedDownBytes) })
+ .FirstOrDefaultAsync(cancellationToken);
+
+ return Result.Success(new StatsDto(
+ userStats.Total, userStats.Activated, pendingActivations,
+ totalNodes, onlineNodes, totalConfigs, activeConfigs,
+ trafficTotals?.Up ?? 0, trafficTotals?.Down ?? 0));
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommand.cs
new file mode 100644
index 0000000..7641dc6
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommand.cs
@@ -0,0 +1,6 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Users;
+
+public sealed record BlockUserCommand(Guid UserId) : ICommand;
diff --git a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs
new file mode 100644
index 0000000..81a160c
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs
@@ -0,0 +1,49 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Audit;
+using PnvPanel.Domain.Configs;
+
+namespace PnvPanel.Application.Admin.Users;
+
+/// Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).
+public sealed class BlockUserCommandHandler(
+ IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
+ IRealtimeNotifier notifier, ICurrentUser currentUser)
+ : ICommandHandler
+{
+ public async Task Handle(BlockUserCommand command, CancellationToken cancellationToken)
+ {
+ var blockResult = await identityService.BlockUserAsync(command.UserId, cancellationToken);
+ if (!blockResult.IsSuccess)
+ return blockResult;
+
+ var configs = await dbContext.VpnConfigs
+ .Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active)
+ .ToListAsync(cancellationToken);
+
+ foreach (var config in configs)
+ {
+ var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
+ var node = inbound is null
+ ? null
+ : await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
+
+ if (inbound is not null && node is not null)
+ {
+ await gateway.UpdateClientAsync(
+ node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
+ config.Label ?? config.ClientEmail, config.DeviceLimit, enable: false, cancellationToken);
+ }
+
+ config.Disable();
+ await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
+ }
+
+ dbContext.AuditLogs.Add(AuditLog.Create(
+ currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
+
+ return Result.Success();
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommand.cs
new file mode 100644
index 0000000..bcb05f5
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommand.cs
@@ -0,0 +1,6 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Users;
+
+public sealed record ForceRevokeConfigCommand(Guid ConfigId) : ICommand;
diff --git a/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs
new file mode 100644
index 0000000..3f8ffd2
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Application.Configs;
+using PnvPanel.Domain.Audit;
+using PnvPanel.Domain.Configs;
+
+namespace PnvPanel.Application.Admin.Users;
+
+public sealed class ForceRevokeConfigCommandHandler(
+ IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier, ICurrentUser currentUser)
+ : ICommandHandler
+{
+ public async Task Handle(ForceRevokeConfigCommand command, CancellationToken cancellationToken)
+ {
+ var config = await dbContext.VpnConfigs.FirstOrDefaultAsync(c => c.Id == command.ConfigId, cancellationToken);
+ if (config is null)
+ return Result.Failure(ConfigErrors.NotFound);
+
+ if (config.Status == ConfigStatus.Revoked)
+ return Result.Success();
+
+ var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
+ var node = inbound is null
+ ? null
+ : await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
+
+ if (inbound is not null && node is not null)
+ await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
+
+ config.Revoke();
+ await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
+
+ dbContext.AuditLogs.Add(AuditLog.Create(
+ currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web));
+
+ return Result.Success();
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQuery.cs b/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQuery.cs
new file mode 100644
index 0000000..ca8ef9d
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQuery.cs
@@ -0,0 +1,7 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Application.Configs;
+
+namespace PnvPanel.Application.Admin.Users;
+
+public sealed record GetUserConfigsQuery(Guid UserId) : IQuery>>;
diff --git a/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQueryHandler.cs
new file mode 100644
index 0000000..853dbaa
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/GetUserConfigsQueryHandler.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Application.Configs;
+using PnvPanel.Domain.Configs;
+
+namespace PnvPanel.Application.Admin.Users;
+
+public sealed class GetUserConfigsQueryHandler(IAppDbContext dbContext)
+ : IQueryHandler>>
+{
+ public async Task>> Handle(GetUserConfigsQuery query, CancellationToken cancellationToken)
+ {
+ var rows = await dbContext.VpnConfigs.AsNoTracking()
+ .Where(c => c.UserId == query.UserId && c.Status != ConfigStatus.Revoked)
+ .Join(dbContext.Inbounds.AsNoTracking(), c => c.InboundId, i => i.Id, (c, i) => new { Config = c, Inbound = i })
+ .OrderByDescending(x => x.Config.CreatedAt)
+ .ToListAsync(cancellationToken);
+
+ var dtos = rows.Select(x => VpnConfigDto.FromDomain(x.Config, x.Inbound)).ToList();
+ return Result.Success>(dtos);
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQuery.cs b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQuery.cs
new file mode 100644
index 0000000..a2d88ef
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQuery.cs
@@ -0,0 +1,7 @@
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Users;
+
+public sealed record ListUsersQuery(int Page, int PageSize, string? Search) : IQuery>>;
diff --git a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs
new file mode 100644
index 0000000..16b0588
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs
@@ -0,0 +1,18 @@
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Users;
+
+public sealed class ListUsersQueryHandler(IIdentityService identityService)
+ : IQueryHandler>>
+{
+ public async Task>> Handle(ListUsersQuery query, CancellationToken cancellationToken)
+ {
+ var page = query.Page <= 0 ? 1 : query.Page;
+ var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
+
+ var result = await identityService.ListUsersAsync(page, pageSize, query.Search, cancellationToken);
+ return Result.Success(result);
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommand.cs
new file mode 100644
index 0000000..48a7f69
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommand.cs
@@ -0,0 +1,6 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Users;
+
+public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand;
diff --git a/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommandHandler.cs
new file mode 100644
index 0000000..7403333
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/ResetUserPasswordCommandHandler.cs
@@ -0,0 +1,23 @@
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Audit;
+
+namespace PnvPanel.Application.Admin.Users;
+
+public sealed class ResetUserPasswordCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
+ : ICommandHandler
+{
+ public async Task Handle(ResetUserPasswordCommand command, CancellationToken cancellationToken)
+ {
+ var result = await identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken);
+ if (!result.IsSuccess)
+ return result;
+
+ dbContext.AuditLogs.Add(AuditLog.Create(
+ currentUser.UserId, "UserPasswordReset", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ return Result.Success();
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommand.cs
new file mode 100644
index 0000000..9905e39
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommand.cs
@@ -0,0 +1,6 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Admin.Users;
+
+public sealed record UnblockUserCommand(Guid UserId) : ICommand;
diff --git a/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs
new file mode 100644
index 0000000..2d798f1
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs
@@ -0,0 +1,49 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Audit;
+using PnvPanel.Domain.Configs;
+
+namespace PnvPanel.Application.Admin.Users;
+
+/// Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled).
+public sealed class UnblockUserCommandHandler(
+ IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
+ IRealtimeNotifier notifier, ICurrentUser currentUser)
+ : ICommandHandler
+{
+ public async Task Handle(UnblockUserCommand command, CancellationToken cancellationToken)
+ {
+ var unblockResult = await identityService.UnblockUserAsync(command.UserId, cancellationToken);
+ if (!unblockResult.IsSuccess)
+ return unblockResult;
+
+ var configs = await dbContext.VpnConfigs
+ .Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled)
+ .ToListAsync(cancellationToken);
+
+ foreach (var config in configs)
+ {
+ var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
+ var node = inbound is null
+ ? null
+ : await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
+
+ if (inbound is not null && node is not null)
+ {
+ await gateway.UpdateClientAsync(
+ node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
+ config.Label ?? config.ClientEmail, config.DeviceLimit, enable: true, cancellationToken);
+ }
+
+ config.Enable();
+ await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
+ }
+
+ dbContext.AuditLogs.Add(AuditLog.Create(
+ currentUser.UserId, "UserUnblocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
+
+ return Result.Success();
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Auth/AuthErrors.cs b/backend/src/PnvPanel.Application/Auth/AuthErrors.cs
index 3e4afc8..afc0a5f 100644
--- a/backend/src/PnvPanel.Application/Auth/AuthErrors.cs
+++ b/backend/src/PnvPanel.Application/Auth/AuthErrors.cs
@@ -18,4 +18,7 @@ public static class AuthErrors
public static readonly Error Unauthorized =
Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация.");
+
+ public static readonly Error UserBlocked =
+ Error.Forbidden("Auth.UserBlocked", "Аккаунт заблокирован администратором.");
}
diff --git a/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs b/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs
index 5d7276c..4a91132 100644
--- a/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs
+++ b/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs
@@ -1,3 +1,3 @@
namespace PnvPanel.Application.Auth;
-public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated);
+public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated, bool TelegramLinked);
diff --git a/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs
index 1210a40..748577b 100644
--- a/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs
@@ -23,7 +23,8 @@ public sealed class LoginCommandHandler(
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(user);
var refreshToken = await refreshTokenService.IssueAsync(user.Id, cancellationToken);
- var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated);
+ var telegramInfo = await identityService.GetTelegramLinkInfoAsync(profile.Id, cancellationToken);
+ var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked);
return Result.Success(new AuthResult(
accessToken,
diff --git a/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs
index 6c39d19..b3c02e7 100644
--- a/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs
+++ b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs
@@ -16,6 +16,8 @@ public sealed class GetCurrentUserQueryHandler(IIdentityService identityService,
if (profile is null)
return Result.Failure(AuthErrors.Unauthorized);
- return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated));
+ var telegramInfo = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
+
+ return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked));
}
}
diff --git a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs
index a4ddc64..3501728 100644
--- a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs
@@ -22,7 +22,8 @@ public sealed class RefreshCommandHandler(
var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser);
- var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated);
+ var telegramInfo = await identityService.GetTelegramLinkInfoAsync(profile.Id, cancellationToken);
+ var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked);
return Result.Success(new AuthResult(
accessToken,
diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs
index 2380c90..760e5e7 100644
--- a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs
+++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs
@@ -2,9 +2,11 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Apps;
+using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Nodes;
+using PnvPanel.Domain.Telegram;
namespace PnvPanel.Application.Common.Interfaces;
@@ -12,12 +14,20 @@ public interface IAppDbContext
{
DbSet ActivationRequests { get; }
+ DbSet AuditLogs { get; }
+
+ DbSet TelegramLinkTokens { get; }
+
+ DbSet TelegramLoginRequests { get; }
+
DbSet Nodes { get; }
DbSet Inbounds { get; }
DbSet VpnConfigs { get; }
+ DbSet TrafficSamples { get; }
+
DbSet ClientApps { get; }
/// Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).
diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs
index 5918a9b..a46f14c 100644
--- a/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs
+++ b/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs
@@ -6,3 +6,13 @@ public interface ICurrentUser
string? UserName { get; }
bool IsAuthenticated { get; }
}
+
+///
+/// Только для фоновых/не-HTTP контекстов (Telegram-бот): задаёт "текущего" пользователя в рамках
+/// одного DI-scope, чтобы переиспользовать те же команды/запросы, что и веб (которые читают
+/// ICurrentUser). Реализуется тем же классом, что и ICurrentUser — см. CurrentUser (Infrastructure).
+///
+public interface ICurrentUserSetter
+{
+ void SetUser(Guid userId, string userName);
+}
diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs
index 2822b31..ae45518 100644
--- a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs
+++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs
@@ -4,7 +4,13 @@ namespace PnvPanel.Application.Common.Interfaces;
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
-public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, int MaxConfigs);
+public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, bool IsBlocked, int MaxConfigs);
+
+public sealed record UserSummaryDto(Guid Id, string UserName, string Role, bool IsActivated, bool IsBlocked, DateTimeOffset? ActivatedAt);
+
+public sealed record UserStatsDto(int Total, int Activated);
+
+public sealed record TelegramLinkInfo(bool IsLinked, long? TelegramUserId, string? Username);
public interface IIdentityService
{
@@ -30,4 +36,24 @@ public interface IIdentityService
/// Для агрегированной подписки /sub/{userToken} (все активные конфиги пользователя).
Task FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken);
+
+ /// Блокировка: вход запрещён (см. ValidateCredentialsAsync). Конфиги гасит вызывающая сторона.
+ Task BlockUserAsync(Guid userId, CancellationToken cancellationToken);
+
+ Task UnblockUserAsync(Guid userId, CancellationToken cancellationToken);
+
+ /// Сброс пароля админом — для пользователей без привязанного Telegram (M7).
+ Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken);
+
+ Task> ListUsersAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
+
+ Task GetUserStatsAsync(CancellationToken cancellationToken);
+
+ Task LinkTelegramAsync(Guid userId, long telegramUserId, string? telegramUsername, CancellationToken cancellationToken);
+
+ Task UnlinkTelegramAsync(Guid userId, CancellationToken cancellationToken);
+
+ Task FindUserIdByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken);
+
+ Task GetTelegramLinkInfoAsync(Guid userId, CancellationToken cancellationToken);
}
diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs
new file mode 100644
index 0000000..50f2529
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs
@@ -0,0 +1,24 @@
+using PnvPanel.Domain.Configs;
+using PnvPanel.Domain.Nodes;
+
+namespace PnvPanel.Application.Common.Interfaces;
+
+///
+/// Пуш событий через SignalR. Реализация (SignalRRealtimeNotifier) живёт в Api (не в Infrastructure) —
+/// ей нужен IHubContext<PanelHub>, а Hub, будучи транспортным механизмом, определён в Api;
+/// Infrastructure не может ссылаться на Api (обратное направление зависимостей).
+///
+public interface IRealtimeNotifier
+{
+ Task NotifyConfigTrafficUpdatedAsync(
+ Guid userId, Guid configId, long usedUpBytes, long usedDownBytes, CancellationToken cancellationToken);
+
+ Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken);
+
+ Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken);
+
+ Task NotifyActivationRequestedAsync(
+ Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken);
+
+ Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken);
+}
diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs
new file mode 100644
index 0000000..e582420
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs
@@ -0,0 +1,15 @@
+namespace PnvPanel.Application.Common.Interfaces;
+
+///
+/// Проактивные DM-уведомления через бота. Реализация — в Api (нужен ITelegramBotClient,
+/// который там же и регистрируется), аналогично IRealtimeNotifier/PanelHub. Если BotToken не
+/// задан — реализация тихо не отправляет ничего (бот работает без Telegram).
+///
+public interface ITelegramNotifier
+{
+ Task NotifyAdminsActivationRequestedAsync(
+ Guid requestId, string userName, string? comment, CancellationToken cancellationToken);
+
+ /// Личное сообщение пользователю, если у него привязан Telegram (иначе no-op).
+ Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken);
+}
diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs
index 5cef6c3..23bb164 100644
--- a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs
+++ b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs
@@ -8,6 +8,8 @@ public sealed record RemoteInboundInfo(string RemoteInboundId, VpnProtocol Proto
public sealed record NodeProbeResult(bool IsReachable, string? ErrorMessage);
+public sealed record ClientTrafficInfo(long UpBytes, long DownBytes);
+
///
/// Оркестрация панелей 3x-ui через ThreeXui.Net. Один BaseAddress в библиотеке, но нод много —
/// реализация держит клиента per-node (кэш по NodeId), см. XuiPanelGateway.
@@ -38,4 +40,11 @@ public interface IXuiPanelGateway
Task> BuildConnectionStringAsync(
Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost,
CancellationToken cancellationToken);
+
+ ///
+ /// Трафик по клиентам инбаунда, ключ — ClientEmail. ThreeXui.Net не даёт типизированного метода
+ /// для этого — извлекается из сырого clientStats[] в RawInboundJson (стандартное поле 3x-ui API).
+ ///
+ Task>> GetClientTrafficAsync(
+ Node node, string inboundRemoteId, CancellationToken cancellationToken);
}
diff --git a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs
index 4e1270a..26cb40f 100644
--- a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs
@@ -7,7 +7,8 @@ using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Configs.Revoke;
-public sealed class RevokeVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
+public sealed class RevokeVpnConfigCommandHandler(
+ IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier, ICurrentUser currentUser)
: ICommandHandler
{
public async Task Handle(RevokeVpnConfigCommand command, CancellationToken cancellationToken)
@@ -32,6 +33,8 @@ public sealed class RevokeVpnConfigCommandHandler(IAppDbContext dbContext, IXuiP
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
config.Revoke();
+ await notifier.NotifyConfigStatusChangedAsync(userId, config.Id, config.Status, cancellationToken);
+
return Result.Success();
}
}
diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommand.cs b/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommand.cs
new file mode 100644
index 0000000..92f7313
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommand.cs
@@ -0,0 +1,6 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Telegram.Bot;
+
+public sealed record ApproveTelegramLoginCommand(Guid RequestId, long TelegramUserId) : ICommand;
diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommandHandler.cs
new file mode 100644
index 0000000..f62ccdb
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/Bot/ApproveTelegramLoginCommandHandler.cs
@@ -0,0 +1,33 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Exceptions;
+
+namespace PnvPanel.Application.Telegram.Bot;
+
+public sealed class ApproveTelegramLoginCommandHandler(IAppDbContext dbContext, IIdentityService identityService)
+ : ICommandHandler
+{
+ public async Task Handle(ApproveTelegramLoginCommand command, CancellationToken cancellationToken)
+ {
+ var userId = await identityService.FindUserIdByTelegramUserIdAsync(command.TelegramUserId, cancellationToken);
+ if (userId is null)
+ return Result.Failure(TelegramErrors.NotLinked);
+
+ var request = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
+ if (request is null)
+ return Result.Failure(TelegramErrors.LoginRequestNotFound);
+
+ try
+ {
+ request.Approve(userId.Value);
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(Error.Conflict("Telegram.LoginRequestInvalid", ex.Message));
+ }
+
+ return Result.Success();
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommand.cs b/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommand.cs
new file mode 100644
index 0000000..44d72be
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommand.cs
@@ -0,0 +1,7 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Telegram.Bot;
+
+/// Вызывается только из TelegramBotHostedService (обработка "/start link_<token>").
+public sealed record LinkTelegramCommand(string Token, long TelegramUserId, string? TelegramUsername) : ICommand>;
diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommandHandler.cs
new file mode 100644
index 0000000..42f6e2f
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/Bot/LinkTelegramCommandHandler.cs
@@ -0,0 +1,26 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Telegram.Bot;
+
+public sealed class LinkTelegramCommandHandler(IAppDbContext dbContext, IIdentityService identityService)
+ : ICommandHandler>
+{
+ public async Task> Handle(LinkTelegramCommand command, CancellationToken cancellationToken)
+ {
+ var linkToken = await dbContext.TelegramLinkTokens.FirstOrDefaultAsync(t => t.Token == command.Token, cancellationToken);
+ if (linkToken is null || !linkToken.IsValid)
+ return Result.Failure(TelegramErrors.LinkTokenNotFound);
+
+ var linkResult = await identityService.LinkTelegramAsync(
+ linkToken.UserId, command.TelegramUserId, command.TelegramUsername, cancellationToken);
+ if (!linkResult.IsSuccess)
+ return Result.Failure(linkResult.Error);
+
+ linkToken.Consume();
+
+ return Result.Success(linkToken.UserId);
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommand.cs b/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommand.cs
new file mode 100644
index 0000000..273af20
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommand.cs
@@ -0,0 +1,6 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Telegram.Bot;
+
+public sealed record RejectTelegramLoginCommand(Guid RequestId, long TelegramUserId) : ICommand;
diff --git a/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommandHandler.cs
new file mode 100644
index 0000000..e8506a3
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/Bot/RejectTelegramLoginCommandHandler.cs
@@ -0,0 +1,33 @@
+using Microsoft.EntityFrameworkCore;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Exceptions;
+
+namespace PnvPanel.Application.Telegram.Bot;
+
+public sealed class RejectTelegramLoginCommandHandler(IAppDbContext dbContext, IIdentityService identityService)
+ : ICommandHandler
+{
+ public async Task Handle(RejectTelegramLoginCommand command, CancellationToken cancellationToken)
+ {
+ var userId = await identityService.FindUserIdByTelegramUserIdAsync(command.TelegramUserId, cancellationToken);
+ if (userId is null)
+ return Result.Failure(TelegramErrors.NotLinked);
+
+ var request = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
+ if (request is null)
+ return Result.Failure(TelegramErrors.LoginRequestNotFound);
+
+ try
+ {
+ request.Reject();
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(Error.Conflict("Telegram.LoginRequestInvalid", ex.Message));
+ }
+
+ return Result.Success();
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommand.cs b/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommand.cs
new file mode 100644
index 0000000..8ede874
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommand.cs
@@ -0,0 +1,8 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Telegram;
+
+public sealed record CreateLinkTokenCommand : ICommand>;
+
+public sealed record LinkTokenDto(string Token, DateTimeOffset ExpiresAt);
diff --git a/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommandHandler.cs
new file mode 100644
index 0000000..5c26dfd
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/CreateLinkTokenCommandHandler.cs
@@ -0,0 +1,24 @@
+using PnvPanel.Application.Auth;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Telegram;
+
+namespace PnvPanel.Application.Telegram;
+
+public sealed class CreateLinkTokenCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
+ : ICommandHandler>
+{
+ private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5);
+
+ public Task> Handle(CreateLinkTokenCommand command, CancellationToken cancellationToken)
+ {
+ if (currentUser.UserId is not { } userId)
+ return Task.FromResult(Result.Failure(AuthErrors.Unauthorized));
+
+ var token = TelegramLinkToken.Create(userId, Ttl);
+ dbContext.TelegramLinkTokens.Add(token);
+
+ return Task.FromResult(Result.Success(new LinkTokenDto(token.Token, token.ExpiresAt)));
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommand.cs b/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommand.cs
new file mode 100644
index 0000000..987af32
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommand.cs
@@ -0,0 +1,9 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Telegram;
+
+/// Публичная команда (пользователь ещё не залогинен) — начинает passwordless-вход.
+public sealed record CreateLoginRequestCommand(string? Context) : ICommand>;
+
+public sealed record LoginRequestDto(Guid RequestId, DateTimeOffset ExpiresAt);
diff --git a/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommandHandler.cs
new file mode 100644
index 0000000..fe9810c
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/CreateLoginRequestCommandHandler.cs
@@ -0,0 +1,20 @@
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Telegram;
+
+namespace PnvPanel.Application.Telegram;
+
+public sealed class CreateLoginRequestCommandHandler(IAppDbContext dbContext)
+ : ICommandHandler>
+{
+ private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5);
+
+ public Task> Handle(CreateLoginRequestCommand command, CancellationToken cancellationToken)
+ {
+ var request = TelegramLoginRequest.Create(Ttl, command.Context);
+ dbContext.TelegramLoginRequests.Add(request);
+
+ return Task.FromResult(Result.Success(new LoginRequestDto(request.Id, request.ExpiresAt)));
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQuery.cs b/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQuery.cs
new file mode 100644
index 0000000..2d77ab2
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQuery.cs
@@ -0,0 +1,10 @@
+using PnvPanel.Application.Auth;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Domain.Telegram;
+
+namespace PnvPanel.Application.Telegram;
+
+public sealed record GetLoginRequestStatusQuery(Guid RequestId) : IQuery>;
+
+public sealed record LoginRequestStatusDto(TelegramLoginStatus Status, AuthResult? Auth);
diff --git a/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQueryHandler.cs b/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQueryHandler.cs
new file mode 100644
index 0000000..0d79dba
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/GetLoginRequestStatusQueryHandler.cs
@@ -0,0 +1,48 @@
+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.Telegram;
+
+namespace PnvPanel.Application.Telegram;
+
+///
+/// Формально Query, но при первом наблюдении Approved-статуса атомарно "забирает" вход:
+/// выпускает JWT и переводит запрос в Consumed (одноразовый claim), см. api-design.md.
+/// Осознанное отступление от чистого CQRS ради простого поллинга без отдельного claim-эндпоинта.
+///
+public sealed class GetLoginRequestStatusQueryHandler(
+ IAppDbContext dbContext, IIdentityService identityService, IJwtTokenService jwtTokenService, IRefreshTokenService refreshTokenService)
+ : IQueryHandler>
+{
+ public async Task> Handle(GetLoginRequestStatusQuery query, CancellationToken cancellationToken)
+ {
+ var request = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync(r => r.Id == query.RequestId, cancellationToken);
+ if (request is null)
+ return Result.Failure(TelegramErrors.LoginRequestNotFound);
+
+ if (request.Status == TelegramLoginStatus.Pending && request.IsExpired)
+ return Result.Success(new LoginRequestStatusDto(TelegramLoginStatus.Expired, null));
+
+ if (request.Status != TelegramLoginStatus.Approved)
+ return Result.Success(new LoginRequestStatusDto(request.Status, null));
+
+ var profile = await identityService.GetProfileAsync(request.UserId!.Value, cancellationToken);
+ if (profile is null)
+ return Result.Failure(AuthErrors.Unauthorized);
+
+ var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role);
+ var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser);
+
+ request.Consume();
+ // IssueAsync сохраняет весь SaveChanges (в т.ч. Consume() выше) — см. RefreshTokenService.
+ var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
+
+ // Пользователь только что подтвердил вход через бота — Telegram точно привязан.
+ var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, TelegramLinked: true);
+ var auth = new AuthResult(accessToken, accessExpiresAt, refreshToken.RawToken, refreshToken.ExpiresAt, dto);
+
+ return Result.Success(new LoginRequestStatusDto(TelegramLoginStatus.Approved, auth));
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs b/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs
new file mode 100644
index 0000000..7570912
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/TelegramErrors.cs
@@ -0,0 +1,15 @@
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Telegram;
+
+public static class TelegramErrors
+{
+ public static readonly Error LinkTokenNotFound =
+ Error.NotFound("Telegram.LinkTokenNotFound", "Токен привязки не найден или истёк.");
+
+ public static readonly Error LoginRequestNotFound =
+ Error.NotFound("Telegram.LoginRequestNotFound", "Запрос на вход не найден.");
+
+ public static readonly Error NotLinked =
+ Error.Conflict("Telegram.NotLinked", "Telegram не привязан ни к одному аккаунту.");
+}
diff --git a/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommand.cs b/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommand.cs
new file mode 100644
index 0000000..ef6e0d7
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommand.cs
@@ -0,0 +1,6 @@
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Telegram;
+
+public sealed record UnlinkTelegramCommand : ICommand;
diff --git a/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommandHandler.cs b/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommandHandler.cs
new file mode 100644
index 0000000..7dd4fb8
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Telegram/UnlinkTelegramCommandHandler.cs
@@ -0,0 +1,18 @@
+using PnvPanel.Application.Auth;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Messaging;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Telegram;
+
+public sealed class UnlinkTelegramCommandHandler(IIdentityService identityService, ICurrentUser currentUser)
+ : ICommandHandler
+{
+ public Task Handle(UnlinkTelegramCommand command, CancellationToken cancellationToken)
+ {
+ if (currentUser.UserId is not { } userId)
+ return Task.FromResult(Result.Failure(AuthErrors.Unauthorized));
+
+ return identityService.UnlinkTelegramAsync(userId, cancellationToken);
+ }
+}
diff --git a/backend/src/PnvPanel.Domain/Audit/AuditLog.cs b/backend/src/PnvPanel.Domain/Audit/AuditLog.cs
new file mode 100644
index 0000000..b5d5177
--- /dev/null
+++ b/backend/src/PnvPanel.Domain/Audit/AuditLog.cs
@@ -0,0 +1,33 @@
+namespace PnvPanel.Domain.Audit;
+
+/// Append-only журнал значимых действий. Id — long (не Guid), см. TrafficSample.
+public sealed class AuditLog
+{
+ public long Id { get; private set; }
+ public Guid? ActorId { get; private set; }
+ public string Action { get; private set; } = string.Empty;
+ public string TargetType { get; private set; } = string.Empty;
+ public string TargetId { get; private set; } = string.Empty;
+ public string? Metadata { get; private set; }
+ public AuditSource Source { get; private set; }
+ public DateTimeOffset CreatedAt { get; private set; }
+
+ private AuditLog()
+ {
+ }
+
+ public static AuditLog Create(
+ Guid? actorId, string action, string targetType, string targetId, string? metadata, AuditSource source)
+ {
+ return new AuditLog
+ {
+ ActorId = actorId,
+ Action = action,
+ TargetType = targetType,
+ TargetId = targetId,
+ Metadata = metadata,
+ Source = source,
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+ }
+}
diff --git a/backend/src/PnvPanel.Domain/Audit/AuditSource.cs b/backend/src/PnvPanel.Domain/Audit/AuditSource.cs
new file mode 100644
index 0000000..ad78fa8
--- /dev/null
+++ b/backend/src/PnvPanel.Domain/Audit/AuditSource.cs
@@ -0,0 +1,8 @@
+namespace PnvPanel.Domain.Audit;
+
+public enum AuditSource
+{
+ Web,
+ Telegram,
+ System,
+}
diff --git a/backend/src/PnvPanel.Domain/Configs/TrafficSample.cs b/backend/src/PnvPanel.Domain/Configs/TrafficSample.cs
new file mode 100644
index 0000000..a19000e
--- /dev/null
+++ b/backend/src/PnvPanel.Domain/Configs/TrafficSample.cs
@@ -0,0 +1,29 @@
+namespace PnvPanel.Domain.Configs;
+
+///
+/// Точка истории трафика. Id — long (не Guid, как у Entity) — таблица растёт быстро,
+/// авто-инкремент компактнее для высокочастотной записи. TTL-ретеншн — см. TrafficRetentionService.
+///
+public sealed class TrafficSample
+{
+ public long Id { get; private set; }
+ public Guid ConfigId { get; private set; }
+ public DateTimeOffset Timestamp { get; private set; }
+ public long UpBytes { get; private set; }
+ public long DownBytes { get; private set; }
+
+ private TrafficSample()
+ {
+ }
+
+ public static TrafficSample Create(Guid configId, DateTimeOffset timestamp, long upBytes, long downBytes)
+ {
+ return new TrafficSample
+ {
+ ConfigId = configId,
+ Timestamp = timestamp,
+ UpBytes = upBytes,
+ DownBytes = downBytes,
+ };
+ }
+}
diff --git a/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs
index 1bee4f9..93ca401 100644
--- a/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs
+++ b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs
@@ -72,6 +72,28 @@ public sealed class VpnConfig : Entity
Status = ConfigStatus.Revoked;
}
+ /// Синхронизация из 3x-ui (см. TrafficSyncService).
+ public void UpdateTraffic(long usedUpBytes, long usedDownBytes)
+ {
+ UsedUpBytes = usedUpBytes;
+ UsedDownBytes = usedDownBytes;
+ LastSyncAt = DateTimeOffset.UtcNow;
+ }
+
+ /// Блокировка пользователя админом — гасит клиента в 3x-ui, но не отзывает запись.
+ public void Disable()
+ {
+ if (Status == ConfigStatus.Active)
+ Status = ConfigStatus.Disabled;
+ }
+
+ /// Разблокировка — возвращает в Active только то, что было погашено блокировкой.
+ public void Enable()
+ {
+ if (Status == ConfigStatus.Disabled)
+ Status = ConfigStatus.Active;
+ }
+
private void EnsureActive(string action)
{
if (Status != ConfigStatus.Active)
diff --git a/backend/src/PnvPanel.Domain/Telegram/TelegramLinkToken.cs b/backend/src/PnvPanel.Domain/Telegram/TelegramLinkToken.cs
new file mode 100644
index 0000000..72aad1f
--- /dev/null
+++ b/backend/src/PnvPanel.Domain/Telegram/TelegramLinkToken.cs
@@ -0,0 +1,41 @@
+using System.Security.Cryptography;
+using PnvPanel.Domain.Common;
+using PnvPanel.Domain.Exceptions;
+
+namespace PnvPanel.Domain.Telegram;
+
+/// Короткоживущий одноразовый токен для флоу привязки Telegram (deep-link в бота).
+public sealed class TelegramLinkToken : Entity
+{
+ public string Token { get; private set; } = string.Empty;
+ public Guid UserId { get; private set; }
+ public DateTimeOffset ExpiresAt { get; private set; }
+ public DateTimeOffset? ConsumedAt { get; private set; }
+
+ private TelegramLinkToken()
+ {
+ }
+
+ public static TelegramLinkToken Create(Guid userId, TimeSpan ttl)
+ {
+ return new TelegramLinkToken
+ {
+ Id = Guid.NewGuid(),
+ Token = GenerateToken(),
+ UserId = userId,
+ ExpiresAt = DateTimeOffset.UtcNow.Add(ttl),
+ };
+ }
+
+ public bool IsValid => ConsumedAt is null && DateTimeOffset.UtcNow < ExpiresAt;
+
+ public void Consume()
+ {
+ if (!IsValid)
+ throw new DomainException("Токен привязки недействителен или уже использован.");
+
+ ConsumedAt = DateTimeOffset.UtcNow;
+ }
+
+ private static string GenerateToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant();
+}
diff --git a/backend/src/PnvPanel.Domain/Telegram/TelegramLoginRequest.cs b/backend/src/PnvPanel.Domain/Telegram/TelegramLoginRequest.cs
new file mode 100644
index 0000000..7f40253
--- /dev/null
+++ b/backend/src/PnvPanel.Domain/Telegram/TelegramLoginRequest.cs
@@ -0,0 +1,69 @@
+using PnvPanel.Domain.Common;
+using PnvPanel.Domain.Exceptions;
+
+namespace PnvPanel.Domain.Telegram;
+
+///
+/// Passwordless-вход: сайт создаёт запрос (Id = nonce в deep-link), пользователь подтверждает
+/// в боте. Context — IP/устройство инициатора, показывается при подтверждении (защита от фишинга).
+///
+public sealed class TelegramLoginRequest : Entity
+{
+ public TelegramLoginStatus Status { get; private set; }
+ public Guid? UserId { get; private set; }
+ public string? Context { get; private set; }
+ public DateTimeOffset CreatedAt { get; private set; }
+ public DateTimeOffset ExpiresAt { get; private set; }
+
+ private TelegramLoginRequest()
+ {
+ }
+
+ public static TelegramLoginRequest Create(TimeSpan ttl, string? context)
+ {
+ return new TelegramLoginRequest
+ {
+ Id = Guid.NewGuid(),
+ Status = TelegramLoginStatus.Pending,
+ Context = context,
+ CreatedAt = DateTimeOffset.UtcNow,
+ ExpiresAt = DateTimeOffset.UtcNow.Add(ttl),
+ };
+ }
+
+ public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
+
+ public void Approve(Guid userId)
+ {
+ EnsurePending();
+ Status = TelegramLoginStatus.Approved;
+ UserId = userId;
+ }
+
+ public void Reject()
+ {
+ EnsurePending();
+ Status = TelegramLoginStatus.Rejected;
+ }
+
+ /// Помечает выданным (после того как сайт забрал JWT по этому запросу).
+ public void Consume()
+ {
+ if (Status != TelegramLoginStatus.Approved)
+ throw new DomainException("Запрос ещё не подтверждён.");
+
+ Status = TelegramLoginStatus.Consumed;
+ }
+
+ private void EnsurePending()
+ {
+ if (IsExpired)
+ {
+ Status = TelegramLoginStatus.Expired;
+ throw new DomainException("Запрос на вход истёк.");
+ }
+
+ if (Status != TelegramLoginStatus.Pending)
+ throw new DomainException("Запрос на вход уже обработан.");
+ }
+}
diff --git a/backend/src/PnvPanel.Domain/Telegram/TelegramLoginStatus.cs b/backend/src/PnvPanel.Domain/Telegram/TelegramLoginStatus.cs
new file mode 100644
index 0000000..3adccb7
--- /dev/null
+++ b/backend/src/PnvPanel.Domain/Telegram/TelegramLoginStatus.cs
@@ -0,0 +1,10 @@
+namespace PnvPanel.Domain.Telegram;
+
+public enum TelegramLoginStatus
+{
+ Pending,
+ Approved,
+ Rejected,
+ Expired,
+ Consumed,
+}
diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs
new file mode 100644
index 0000000..a91087a
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs
@@ -0,0 +1,55 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Domain.Nodes;
+using PnvPanel.Infrastructure.Persistence;
+
+namespace PnvPanel.Infrastructure.BackgroundJobs;
+
+public sealed class NodeHealthCheckService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService
+{
+ private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2);
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ using var timer = new PeriodicTimer(Interval);
+ do
+ {
+ try
+ {
+ await CheckAllAsync(stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Ошибка health-check нод");
+ }
+ }
+ while (await timer.WaitForNextTickAsync(stoppingToken));
+ }
+
+ private async Task CheckAllAsync(CancellationToken cancellationToken)
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ var gateway = scope.ServiceProvider.GetRequiredService();
+ var notifier = scope.ServiceProvider.GetRequiredService();
+
+ var nodes = await dbContext.Nodes.Where(n => n.IsEnabled).ToListAsync(cancellationToken);
+
+ foreach (var node in nodes)
+ {
+ var probe = await gateway.ProbeAsync(node, cancellationToken);
+ var newStatus = probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline;
+
+ if (node.Status != newStatus)
+ {
+ node.UpdateStatus(newStatus);
+ await notifier.NotifyNodeStatusChangedAsync(node.Id, newStatus, node.LastSyncAt, cancellationToken);
+ }
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionOptions.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionOptions.cs
new file mode 100644
index 0000000..ddbe260
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionOptions.cs
@@ -0,0 +1,8 @@
+namespace PnvPanel.Infrastructure.BackgroundJobs;
+
+public sealed class TrafficRetentionOptions
+{
+ public const string SectionName = "TrafficRetention";
+
+ public int RetentionDays { get; init; } = 30;
+}
diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionService.cs
new file mode 100644
index 0000000..7707cc1
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficRetentionService.cs
@@ -0,0 +1,45 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using PnvPanel.Infrastructure.Persistence;
+
+namespace PnvPanel.Infrastructure.BackgroundJobs;
+
+/// TTL-чистка истории трафика (TrafficRetention__RetentionDays, по умолчанию 30 дней).
+public sealed class TrafficRetentionService(
+ IServiceScopeFactory scopeFactory, IOptions options, ILogger logger)
+ : BackgroundService
+{
+ private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ using var timer = new PeriodicTimer(Interval);
+ do
+ {
+ try
+ {
+ await CleanupAsync(stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Ошибка ретеншна истории трафика");
+ }
+ }
+ while (await timer.WaitForNextTickAsync(stoppingToken));
+ }
+
+ private async Task CleanupAsync(CancellationToken cancellationToken)
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ var cutoff = DateTimeOffset.UtcNow.AddDays(-options.Value.RetentionDays);
+ var deleted = await dbContext.TrafficSamples.Where(s => s.Timestamp < cutoff).ExecuteDeleteAsync(cancellationToken);
+
+ if (deleted > 0)
+ logger.LogInformation("Удалено {Count} устаревших записей истории трафика", deleted);
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficSyncService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficSyncService.cs
new file mode 100644
index 0000000..1de9581
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/TrafficSyncService.cs
@@ -0,0 +1,79 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Domain.Configs;
+using PnvPanel.Infrastructure.Persistence;
+
+namespace PnvPanel.Infrastructure.BackgroundJobs;
+
+///
+/// Обходит включённые ноды → инбаунды → активные конфиги, тянет клиентский трафик и обновляет
+/// VpnConfig + пишет TrafficSample. Реконсиляция дрейфа: если панель недоступна — просто пропускаем
+/// эту ноду в этом цикле, не роняем весь сервис и не трогаем локальные данные.
+///
+public sealed class TrafficSyncService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService
+{
+ private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ using var timer = new PeriodicTimer(Interval);
+ do
+ {
+ try
+ {
+ await SyncAllAsync(stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Ошибка синхронизации трафика");
+ }
+ }
+ while (await timer.WaitForNextTickAsync(stoppingToken));
+ }
+
+ private async Task SyncAllAsync(CancellationToken cancellationToken)
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ var gateway = scope.ServiceProvider.GetRequiredService();
+ var notifier = scope.ServiceProvider.GetRequiredService();
+
+ var nodes = await dbContext.Nodes.Where(n => n.IsEnabled).ToListAsync(cancellationToken);
+
+ foreach (var node in nodes)
+ {
+ var inbounds = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken);
+
+ foreach (var inbound in inbounds)
+ {
+ var configs = await dbContext.VpnConfigs
+ .Where(c => c.InboundId == inbound.Id && c.Status == ConfigStatus.Active)
+ .ToListAsync(cancellationToken);
+
+ if (configs.Count == 0)
+ continue;
+
+ var trafficResult = await gateway.GetClientTrafficAsync(node, inbound.RemoteInboundId, cancellationToken);
+ if (!trafficResult.IsSuccess)
+ continue;
+
+ foreach (var config in configs)
+ {
+ if (!trafficResult.Value.TryGetValue(config.ClientEmail, out var traffic))
+ continue;
+
+ config.UpdateTraffic(traffic.UpBytes, traffic.DownBytes);
+ dbContext.TrafficSamples.Add(TrafficSample.Create(config.Id, DateTimeOffset.UtcNow, traffic.UpBytes, traffic.DownBytes));
+
+ await notifier.NotifyConfigTrafficUpdatedAsync(
+ config.UserId, config.Id, traffic.UpBytes, traffic.DownBytes, cancellationToken);
+ }
+ }
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs
index 73fb77e..180339d 100644
--- a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs
+++ b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs
@@ -7,9 +7,11 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Infrastructure.BackgroundJobs;
using PnvPanel.Infrastructure.Identity;
using PnvPanel.Infrastructure.Persistence;
using PnvPanel.Infrastructure.Security;
+using PnvPanel.Infrastructure.Telegram;
using PnvPanel.Infrastructure.Xui;
using ThreeXui.ConnectionStrings;
using ThreeXui.Http;
@@ -66,6 +68,20 @@ public static class DependencyInjection
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
+
+ // SignalR JS-клиент не может выставить заголовок Authorization на WebSocket-хендшейке —
+ // передаёт токен через query-string (?access_token=...), только для /hubs.
+ options.Events = new JwtBearerEvents
+ {
+ OnMessageReceived = context =>
+ {
+ var accessToken = context.Request.Query["access_token"];
+ if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
+ context.Token = accessToken;
+
+ return Task.CompletedTask;
+ },
+ };
});
services.AddAuthorization();
@@ -94,9 +110,20 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
- services.AddScoped();
+ // Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как
+ // ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService).
+ services.AddScoped();
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped(sp => sp.GetRequiredService());
services.AddScoped();
+ services.Configure(configuration.GetSection(TrafficRetentionOptions.SectionName));
+ services.AddHostedService();
+ services.AddHostedService();
+ services.AddHostedService();
+
+ services.Configure(configuration.GetSection(TelegramOptions.SectionName));
+
return services;
}
}
diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs
index cd9f566..faf597e 100644
--- a/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs
+++ b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs
@@ -12,6 +12,16 @@ public class AppUser : IdentityUser
public DateTimeOffset? ActivatedAt { get; set; }
public Guid? ActivatedBy { get; set; }
+ /// Блокировка админом: вход запрещён, все конфиги отключаются в 3x-ui (см. BlockUserCommandHandler).
+ public bool IsBlocked { get; set; }
+
/// Секрет для агрегированной подписки /sub/{token} (все активные конфиги пользователя).
public string SubscriptionToken { get; set; } = string.Empty;
+
+ /// Id пользователя Telegram; уникален; null до привязки.
+ public long? TelegramUserId { get; set; }
+
+ public string? TelegramUsername { get; set; }
+
+ public DateTimeOffset? TelegramLinkedAt { get; set; }
}
diff --git a/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs b/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs
index e0e85d1..dea9fbc 100644
--- a/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs
+++ b/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs
@@ -4,20 +4,27 @@ using PnvPanel.Application.Common.Interfaces;
namespace PnvPanel.Infrastructure.Identity;
-internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser
+///
+/// В HTTP-запросах читает JWT-claims. В Telegram-боте (нет HttpContext) вызывающая сторона
+/// заранее задаёт пользователя через ICurrentUserSetter.SetUser(...) в рамках DI-scope апдейта.
+///
+internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser, ICurrentUserSetter
{
+ private (Guid Id, string Name)? _override;
+
private ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User;
- public Guid? UserId
+ public Guid? UserId => _override?.Id ?? ParseHttpUserId();
+
+ public string? UserName => _override?.Name ?? Principal?.FindFirstValue(ClaimTypes.Name);
+
+ public bool IsAuthenticated => _override is not null || (Principal?.Identity?.IsAuthenticated ?? false);
+
+ public void SetUser(Guid userId, string userName) => _override = (userId, userName);
+
+ private Guid? ParseHttpUserId()
{
- get
- {
- var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
- return Guid.TryParse(value, out var id) ? id : null;
- }
+ var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
+ return Guid.TryParse(value, out var id) ? id : null;
}
-
- public string? UserName => Principal?.FindFirstValue(ClaimTypes.Name);
-
- public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false;
}
diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs
index ff9a8f3..4c6f8dd 100644
--- a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs
+++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs
@@ -39,6 +39,9 @@ internal sealed class IdentityService(UserManager userManager, SignInMa
if (user is null)
return Result.Failure(AuthErrors.InvalidCredentials);
+ if (user.IsBlocked)
+ return Result.Failure(AuthErrors.UserBlocked);
+
var checkResult = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true);
if (checkResult.IsLockedOut)
return Result.Failure(AuthErrors.LockedOut);
@@ -56,7 +59,7 @@ internal sealed class IdentityService(UserManager userManager, SignInMa
return null;
var role = await GetPrimaryRoleAsync(user);
- return new CurrentUserProfile(user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, role.MaxConfigs);
+ return new CurrentUserProfile(user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs);
}
public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken)
@@ -118,6 +121,118 @@ internal sealed class IdentityService(UserManager userManager, SignInMa
return user?.Id;
}
+ public async Task BlockUserAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ if (user is null)
+ return Result.Failure(AuthErrors.Unauthorized);
+
+ user.IsBlocked = true;
+ await userManager.UpdateSecurityStampAsync(user); // гасит уже выданные refresh-токены де-факто — токен привязан к пользователю, не к stamp; отзыв делает вызывающий handler явно
+ return Result.Success();
+ }
+
+ public async Task UnblockUserAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ if (user is null)
+ return Result.Failure(AuthErrors.Unauthorized);
+
+ user.IsBlocked = false;
+ return Result.Success();
+ }
+
+ public async Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken)
+ {
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ if (user is null)
+ return Result.Failure(AuthErrors.Unauthorized);
+
+ var token = await userManager.GeneratePasswordResetTokenAsync(user);
+ var result = await userManager.ResetPasswordAsync(user, token, newPassword);
+
+ return result.Succeeded
+ ? Result.Success()
+ : Result.Failure(Error.Validation(
+ "Auth.PasswordResetFailed", string.Join("; ", result.Errors.Select(e => e.Description))));
+ }
+
+ public async Task> ListUsersAsync(int page, int pageSize, string? search, CancellationToken cancellationToken)
+ {
+ var query = userManager.Users.AsNoTracking();
+ if (!string.IsNullOrWhiteSpace(search))
+ query = query.Where(u => u.UserName!.Contains(search));
+
+ var total = await query.CountAsync(cancellationToken);
+ var users = await query
+ .OrderBy(u => u.UserName)
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToListAsync(cancellationToken);
+
+ var items = new List(users.Count);
+ foreach (var user in users)
+ {
+ var roleName = await GetPrimaryRoleNameAsync(user);
+ items.Add(new UserSummaryDto(user.Id, user.UserName!, roleName, user.IsActivated, user.IsBlocked, user.ActivatedAt));
+ }
+
+ return new PagedList(items, total, page, pageSize);
+ }
+
+ public async Task GetUserStatsAsync(CancellationToken cancellationToken)
+ {
+ var total = await userManager.Users.CountAsync(cancellationToken);
+ var activated = await userManager.Users.CountAsync(u => u.IsActivated, cancellationToken);
+ return new UserStatsDto(total, activated);
+ }
+
+ public async Task LinkTelegramAsync(Guid userId, long telegramUserId, string? telegramUsername, CancellationToken cancellationToken)
+ {
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ if (user is null)
+ return Result.Failure(AuthErrors.Unauthorized);
+
+ var alreadyLinked = await userManager.Users.AsNoTracking()
+ .AnyAsync(u => u.TelegramUserId == telegramUserId && u.Id != userId, cancellationToken);
+ if (alreadyLinked)
+ return Result.Failure(Error.Conflict("Telegram.AlreadyLinked", "Этот Telegram уже привязан к другому аккаунту."));
+
+ user.TelegramUserId = telegramUserId;
+ user.TelegramUsername = telegramUsername;
+ user.TelegramLinkedAt = DateTimeOffset.UtcNow;
+
+ return Result.Success();
+ }
+
+ public async Task UnlinkTelegramAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ if (user is null)
+ return Result.Failure(AuthErrors.Unauthorized);
+
+ user.TelegramUserId = null;
+ user.TelegramUsername = null;
+ user.TelegramLinkedAt = null;
+
+ return Result.Success();
+ }
+
+ public async Task FindUserIdByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken)
+ {
+ var user = await userManager.Users.AsNoTracking()
+ .FirstOrDefaultAsync(u => u.TelegramUserId == telegramUserId, cancellationToken);
+ return user?.Id;
+ }
+
+ public async Task GetTelegramLinkInfoAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ return user?.TelegramUserId is not null
+ ? new TelegramLinkInfo(true, user.TelegramUserId, user.TelegramUsername)
+ : new TelegramLinkInfo(false, null, null);
+ }
+
private async Task GetPrimaryRoleNameAsync(AppUser user)
{
var roles = await userManager.GetRolesAsync(user);
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs
index 4e18d9a..f7006d1 100644
--- a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs
@@ -3,9 +3,11 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Apps;
+using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Nodes;
+using PnvPanel.Domain.Telegram;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Infrastructure.Persistence;
@@ -21,12 +23,20 @@ public class AppDbContext(DbContextOptions options)
public DbSet ActivationRequests => Set();
+ public DbSet AuditLogs => Set();
+
+ public DbSet TelegramLinkTokens => Set();
+
+ public DbSet TelegramLoginRequests => Set();
+
public DbSet Nodes => Set();
public DbSet Inbounds => Set();
public DbSet VpnConfigs => Set();
+ public DbSet TrafficSamples => Set();
+
public DbSet ClientApps => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs
new file mode 100644
index 0000000..f573a39
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs
@@ -0,0 +1,15 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PnvPanel.Infrastructure.Identity;
+
+namespace PnvPanel.Infrastructure.Persistence.Configurations;
+
+public class AppUserConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ // Уникален среди привязанных (Postgres не считает NULL равным NULL — обычный unique подходит).
+ builder.HasIndex(x => x.TelegramUserId).IsUnique();
+ builder.HasIndex(x => x.SubscriptionToken).IsUnique();
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs
new file mode 100644
index 0000000..cf51f16
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs
@@ -0,0 +1,23 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PnvPanel.Domain.Audit;
+
+namespace PnvPanel.Infrastructure.Persistence.Configurations;
+
+public class AuditLogConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("AuditLogs");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.Id).ValueGeneratedOnAdd();
+
+ builder.Property(x => x.Action).IsRequired().HasMaxLength(100);
+ builder.Property(x => x.TargetType).IsRequired().HasMaxLength(50);
+ builder.Property(x => x.TargetId).IsRequired().HasMaxLength(100);
+ builder.Property(x => x.Metadata).HasColumnType("jsonb");
+ builder.Property(x => x.Source).HasConversion().HasMaxLength(32);
+
+ builder.HasIndex(x => x.CreatedAt);
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLinkTokenConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLinkTokenConfiguration.cs
new file mode 100644
index 0000000..88858f8
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLinkTokenConfiguration.cs
@@ -0,0 +1,17 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PnvPanel.Domain.Telegram;
+
+namespace PnvPanel.Infrastructure.Persistence.Configurations;
+
+public class TelegramLinkTokenConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("TelegramLinkTokens");
+ builder.HasKey(x => x.Id);
+
+ builder.Property(x => x.Token).IsRequired().HasMaxLength(64);
+ builder.HasIndex(x => x.Token).IsUnique();
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLoginRequestConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLoginRequestConfiguration.cs
new file mode 100644
index 0000000..637505f
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TelegramLoginRequestConfiguration.cs
@@ -0,0 +1,17 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PnvPanel.Domain.Telegram;
+
+namespace PnvPanel.Infrastructure.Persistence.Configurations;
+
+public class TelegramLoginRequestConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("TelegramLoginRequests");
+ builder.HasKey(x => x.Id);
+
+ builder.Property(x => x.Status).HasConversion().HasMaxLength(32);
+ builder.Property(x => x.Context).HasMaxLength(200);
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TrafficSampleConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TrafficSampleConfiguration.cs
new file mode 100644
index 0000000..f48d81d
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TrafficSampleConfiguration.cs
@@ -0,0 +1,17 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PnvPanel.Domain.Configs;
+
+namespace PnvPanel.Infrastructure.Persistence.Configurations;
+
+public class TrafficSampleConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("TrafficSamples");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.Id).ValueGeneratedOnAdd();
+
+ builder.HasIndex(x => new { x.ConfigId, x.Timestamp });
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701194500_AddTrafficSamples.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701194500_AddTrafficSamples.Designer.cs
new file mode 100644
index 0000000..42f61fc
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701194500_AddTrafficSamples.Designer.cs
@@ -0,0 +1,628 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using PnvPanel.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace PnvPanel.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260701194500_AddTrafficSamples")]
+ partial class AddTrafficSamples
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Comment")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecidedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecidedBy")
+ .HasColumnType("uuid");
+
+ b.Property("RejectionReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Status");
+
+ b.ToTable("ActivationRequests", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("DownloadUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("IconUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property