From 22bc964f27a3c8355cb02b41a7716d70cdfef8e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sun, 29 Mar 2026 14:26:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B8=D0=BB=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/Host/Program.cs | 19 ++- .../TestKlipy/TestKlipyConnectionCommand.cs | 2 +- .../Application/Admin/DTOs/AdminUserDto.cs | 4 +- .../Queries/CleanDryRun/CleanDryRunQuery.cs | 124 ++++++++++------- .../Queries/SearchUsers/SearchUsersQuery.cs | 65 ++++++--- .../Modules/Admin/Knot.Modules.Admin.csproj | 1 + .../Presentation/Endpoints/AdminEndpoints.cs | 25 ++-- .../Abstractions/IUserStatusService.cs | 8 ++ .../Conversations/DependencyInjection.cs | 1 + .../Services/UserStatusService.cs | 12 ++ .../Application/Abstractions/IKlipyClient.cs | 2 +- .../src/Modules/Klipy/DependencyInjection.cs | 7 +- .../Infrastructure/External/KlipyClient.cs | 4 +- .../Modules/Klipy/Knot.Modules.Klipy.csproj | 6 +- .../Abstractions/IUserStatsService.cs | 16 +++ .../Modules/Messaging/DependencyInjection.cs | 1 + .../Modules/Messaging/Domain/MediaMessage.cs | 2 + .../src/Modules/Messaging/Domain/Message.cs | 4 + .../Modules/Messaging/Domain/PollMessage.cs | 2 + .../Modules/Messaging/Domain/StoryMessage.cs | 2 + .../Modules/Messaging/Domain/TextMessage.cs | 2 + .../Mongo/MongoDbMapConfigurator.cs | 4 +- .../Persistence/Mongo/UserStatsService.cs | 75 ++++++++++ .../Settings/DTOs/PublicConfigDto.cs | 4 +- .../Settings/DTOs/SystemSettingsDto.cs | 3 +- .../Storage/S3FileStorageService.cs | 6 +- .../Modules/Stories/DependencyInjection.cs | 2 - .../Mongo/StoriesMongoMapConfigurator.cs | 2 +- .../admin/presentation/pages/AdminPage.tsx | 130 ++++++++++++++---- 29 files changed, 405 insertions(+), 130 deletions(-) create mode 100644 backend/src/Modules/Conversations/Application/Abstractions/IUserStatusService.cs create mode 100644 backend/src/Modules/Conversations/Infrastructure/Services/UserStatusService.cs rename backend/src/Modules/{Stories => Klipy}/Application/Abstractions/IKlipyClient.cs (86%) rename backend/src/Modules/{Stories => Klipy}/Infrastructure/External/KlipyClient.cs (91%) create mode 100644 backend/src/Modules/Messaging/Application/Abstractions/IUserStatsService.cs create mode 100644 backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs diff --git a/backend/src/Host/Program.cs b/backend/src/Host/Program.cs index 248c5c8..36ab184 100644 --- a/backend/src/Host/Program.cs +++ b/backend/src/Host/Program.cs @@ -9,6 +9,9 @@ using Knot.Modules.Conversations; using Knot.Modules.Conversations.Infrastructure.SignalR; using Knot.Modules.Auth.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence; +using Knot.Modules.Storage; +using Knot.Modules.Stories; +using Knot.Modules.Klipy; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; @@ -54,15 +57,24 @@ builder.Configuration.AddInMemoryCollection( builder.Services.AddAuthModule(builder.Configuration); builder.Services.AddSettingsModule(builder.Configuration); +builder.Services.AddMessagingModule(builder.Configuration); builder.Services.AddConversationsModule(builder.Configuration); builder.Services.AddProfilesModule(builder.Configuration); +builder.Services.AddStorageModule(builder.Configuration); +builder.Services.AddStoriesModule(builder.Configuration); +builder.Services.AddKlipyModule(); +builder.Services.AddAdminModule(); builder.Services.AddSharedInfrastructure(builder.Configuration); // CQRS / MediatR для команд в Host (например, AdminController) -builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( + typeof(Program).Assembly, typeof(Knot.Modules.Settings.DependencyInjection).Assembly, - typeof(Knot.Modules.Admin.DependencyInjection).Assembly + typeof(Knot.Modules.Admin.DependencyInjection).Assembly, + typeof(Knot.Modules.Messaging.DependencyInjection).Assembly, + typeof(Knot.Modules.Conversations.DependencyInjection).Assembly, + typeof(Knot.Modules.Stories.DependencyInjection).Assembly, + typeof(Knot.Modules.Klipy.DependencyInjection).Assembly )); // Carter для вызова Minimal APIs (Endpoints) @@ -170,6 +182,9 @@ using (var scope = app.Services.CreateScope()) var systemDb = scope.ServiceProvider.GetRequiredService(); await systemDb.Database.MigrateAsync(); + var storiesDb = scope.ServiceProvider.GetRequiredService(); + await storiesDb.Database.MigrateAsync(); + // Set Encryption Service for MongoDB serializers var encryptionService = scope.ServiceProvider.GetRequiredService(); Knot.Modules.Messaging.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService; diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs index 7abe326..372ab7d 100644 --- a/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs +++ b/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs @@ -4,7 +4,7 @@ using MediatR; using Knot.Shared.Kernel; using Knot.Modules.Settings.Application.Settings.Abstractions; using Knot.Modules.Settings.Application.Settings.DTOs; -using Knot.Modules.Stories.Application.Abstractions; +using Knot.Modules.Klipy.Application.Abstractions; namespace Knot.Modules.Admin.Application.Admin.Commands.TestKlipy; diff --git a/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDto.cs b/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDto.cs index 78625af..85d27b4 100644 --- a/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDto.cs +++ b/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDto.cs @@ -11,5 +11,7 @@ public record AdminUserDto( DateTime CreatedAt, bool IsOnline, DateTime LastOnlineAt, - bool IsBanned + bool IsBanned, + int? MessageCount = 0, + long? StorageSize = 0 ); \ No newline at end of file diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs index 44f9101..7eab692 100644 --- a/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs +++ b/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs @@ -9,6 +9,7 @@ using MediatR; using Knot.Shared.Kernel; using Knot.Shared.Kernel.Storage; using MongoDB.Driver; +using MongoDB.Bson; using Microsoft.EntityFrameworkCore; using Knot.Modules.Auth.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence; @@ -37,79 +38,98 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler> Handle(CleanDryRunQuery request, CancellationToken cancellationToken) + public async Task> Handle(CleanDryRunQuery request, CancellationToken ct) { try { - var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); - var activeChatIds = allChats.Select(c => c.Id).ToHashSet(); - - var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken); - - var orphanedMessages = allMessages - .Where(m => !activeChatIds.Contains(m.ChatId) || m.IsDeleted) - .ToList(); - - var keptMessages = allMessages - .Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted) - .ToList(); - - var orphanedMessagesCount = orphanedMessages.Count; - - var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList(); - - var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken); + // 1. Получаем ID активных чатов (SQL) + var activeChats = await _chatsDbContext.Chats + .AsNoTracking() + .Select(c => new { c.Id, c.Avatar }) + .ToListAsync(ct); - var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken); + var activeChatIds = activeChats.Select(c => c.Id).ToHashSet(); - var validUrls = new HashSet(); + // 2. Считаем сообщения подлежащие удалению (MongoDB) + var orphanedFilter = Builders.Filter.Or( + Builders.Filter.BitsAnySet(m => m.State, (long)MessageState.IsDeleted), + Builders.Filter.Nin(m => m.ChatId, activeChatIds) + ); + var orphanedMessagesCount = await _messages.CountDocumentsAsync(orphanedFilter, cancellationToken: ct); - var activeMessageUrls = keptMessages.OfType() - .Where(m => m.Media != null) - .SelectMany(m => m.Media) - .Select(me => me.Url) - .Where(u => !string.IsNullOrEmpty(u)); + // 3. Собираем ID всех используемых файлов + var validFileIds = new HashSet(); - var activeChatUrls = allChats - .Where(c => !string.IsNullOrEmpty(c.Avatar)) - .Select(c => c.Avatar!); - - var activeUserUrls = allUsers + // Аватары чатов и пользователей + foreach (var chat in activeChats) AddFileIdIfValid(chat.Avatar, validFileIds); + + var userAvatars = await _identityDb.Users.AsNoTracking() .Where(u => !string.IsNullOrEmpty(u.Avatar)) - .Select(u => u.Avatar!); - - var activeStoryUrls = allStories - .Where(s => !string.IsNullOrEmpty(s.MediaUrl)) - .Select(s => s.MediaUrl!); + .Select(u => u.Avatar).ToListAsync(ct); + foreach (var avatar in userAvatars) AddFileIdIfValid(avatar, validFileIds); - foreach (var u in activeMessageUrls) validUrls.Add(u!); - foreach (var u in activeChatUrls) validUrls.Add(u); - foreach (var u in activeUserUrls) validUrls.Add(u); - foreach (var u in activeStoryUrls) validUrls.Add(u); + // Медиа из актуальных сторис + var storiesMedia = await _stories.Find(s => s.CreatedAt > DateTime.UtcNow.AddHours(-24)) + .Project(s => s.MediaUrl).ToListAsync(ct); + foreach (var url in storiesMedia) AddFileIdIfValid(url, validFileIds); - var validFileIds = validUrls - .Where(u => u.Contains("/api/files/")) - .Select(u => u.Split('/').Last()) - .ToHashSet(); - - long safeBytes = 0; - foreach (var file in allMinioFiles) + // Медиа из активных сообщений (Проекция для скорости) + var activeFilter = Builders.Filter.And( + Builders.Filter.BitsAllClear(m => m.State, (long)MessageState.IsDeleted), + Builders.Filter.In(m => m.ChatId, activeChatIds) + ); + + var projection = Builders.Projection.Include("Media"); + + using (var cursor = await _messages.Find(activeFilter).Project(projection).ToCursorAsync(ct)) { - if (!validFileIds.Contains(file.FileId)) + while (await cursor.MoveNextAsync(ct)) { - safeBytes += file.Size; + foreach (var doc in cursor.Current) + { + if (doc.Contains("Media") && doc["Media"].IsBsonArray) + { + foreach (var media in doc["Media"].AsBsonArray) + { + if (media.IsBsonDocument && media.AsBsonDocument.Contains("Url")) + AddFileIdIfValid(media.AsBsonDocument["Url"].AsString, validFileIds); + } + } + } } } + // 4. Анализ физического хранилища + var allStoredFiles = await _fileStorage.ListFilesAsync(); + long orphanedBytes = allStoredFiles + .Where(file => !validFileIds.Contains(file.FileId)) + .Sum(file => file.Size); + return Result.Success(new CleanupDryRunResultDto { - OrphanedMessagesCount = orphanedMessagesCount, - OrphanedMediaBytes = safeBytes + OrphanedMessagesCount = (int)orphanedMessagesCount, + OrphanedMediaBytes = orphanedBytes }); } catch (Exception ex) { - return Result.Failure(new Error("Cleanup.ProcessError", $"Failed to analyze junk data: {ex.Message}")); + return Result.Failure(new Error("Cleanup.Error", $"Ошибка при анализе данных: {ex.Message}")); + } + } + + private void AddFileIdIfValid(string? url, HashSet validIds) + { + if (string.IsNullOrEmpty(url)) return; + + // Предполагаем формат /api/files/{id} + if (url.Contains("/api/files/")) + { + var parts = url.Split('/'); + var fileId = parts.LastOrDefault(); + if (!string.IsNullOrEmpty(fileId)) + { + validIds.Add(fileId); + } } } } diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs index 0f0fb40..79a8ca9 100644 --- a/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs +++ b/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs @@ -6,10 +6,10 @@ using System.Threading; using System.Threading.Tasks; using MediatR; using Knot.Shared.Kernel; -using Knot.Modules.Auth.Application.Auth.DTOs; - using Knot.Modules.Auth.Application.Users; using Knot.Modules.Auth.Domain; +using Knot.Modules.Messaging.Application.Abstractions; +using Knot.Modules.Conversations.Application.Abstractions; namespace Knot.Modules.Admin.Application.Admin.Queries; @@ -18,30 +18,57 @@ public record SearchUsersQuery(string Query) : IQuery>; internal sealed class SearchUsersQueryHandler : IQueryHandler> { private readonly IUserRepository _userRepository; + private readonly IUserStatsService _statsService; + private readonly IUserStatusService _statusService; - public SearchUsersQueryHandler(IUserRepository userRepository) + public SearchUsersQueryHandler( + IUserRepository userRepository, + IUserStatsService statsService, + IUserStatusService statusService) { _userRepository = userRepository; + _statsService = statsService; + _statusService = statusService; } - public async Task>> Handle(SearchUsersQuery request, CancellationToken cancellationToken) + public async Task>> Handle(SearchUsersQuery request, CancellationToken ct) { - var users = string.IsNullOrWhiteSpace(request.Query) - ? await _userRepository.SearchUsersAsync("", cancellationToken) - : await _userRepository.SearchUsersAsync(request.Query, cancellationToken); + try + { + // 1. Поиск пользователей в реляционной БД + var users = await _userRepository.SearchUsersAsync(request.Query ?? "", ct); + if (!users.Any()) return Result.Success(new List()); - var result = users.Select(u => new AdminUserDto( - u.Id, - u.Username, - u.DisplayName, - u.Email, - u.Avatar, - u.CreatedAt, - Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()), - Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt, - u.IsBanned - )).ToList(); + var userIds = users.Select(u => u.Id).ToList(); - return Result.Success(result); + // 2. Получение агрегированной статистики из NoSQL + var statsMap = await _statsService.GetStatsForUsersAsync(userIds, ct); + + // 3. Сборка DTO с использованием сервисов статуса и статистики + var result = users.Select(u => { + var stats = statsMap.GetValueOrDefault(u.Id, new UserStats(0, 0L)); + var isOnline = _statusService.IsUserOnline(u.Id.ToString()); + + return new AdminUserDto( + u.Id, + u.Username ?? "unknown", + u.DisplayName ?? "User", + u.Email, + u.Avatar, + u.CreatedAt, + isOnline, + isOnline ? DateTime.UtcNow : (u.LastSeen ?? u.CreatedAt), + u.IsBanned, + stats.MessageCount, + stats.StorageSize + ); + }).ToList(); + + return Result.Success(result); + } + catch (Exception ex) + { + return Result.Failure>(new Error("Admin.SearchUsers.Error", $"Ошибка при поиске пользователей: {ex.Message}")); + } } } diff --git a/backend/src/Modules/Admin/Knot.Modules.Admin.csproj b/backend/src/Modules/Admin/Knot.Modules.Admin.csproj index 73227c8..41f0ce4 100644 --- a/backend/src/Modules/Admin/Knot.Modules.Admin.csproj +++ b/backend/src/Modules/Admin/Knot.Modules.Admin.csproj @@ -10,6 +10,7 @@ + diff --git a/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs b/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs index ab5432f..61e420c 100644 --- a/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs +++ b/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs @@ -92,6 +92,10 @@ public sealed class AdminEndpoints : ICarterModule group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) => { var result = await sender.Send(new SearchUsersQuery(query), ct); + if (!result.IsSuccess) + { + return Results.BadRequest(new { error = result.Error.Description }); + } return Results.Ok(result.Value); }); @@ -112,15 +116,20 @@ public sealed class AdminEndpoints : ICarterModule return Results.Ok(result.Value); }); - group.MapPost("clean/run", async (ISender sender, CancellationToken ct) => + group.MapGet("timezones", () => { - var result = await sender.Send(new CleanRunCommand(), ct); - if (!result.IsSuccess) - { - Console.WriteLine($"[Admin] Cleanup Run Error: {result.Error.Description}"); - return Results.BadRequest(new { error = result.Error.Description }); - } - return Results.Ok(result.Value); + // Получаем все системные часовые пояса и формируем удобный для фронтенда формат + var zones = TimeZoneInfo.GetSystemTimeZones() + .Select(tz => new { + id = tz.Id, + displayName = tz.DisplayName, + standardName = tz.StandardName, + offsetMinutes = tz.BaseUtcOffset.TotalMinutes, + offsetString = (tz.BaseUtcOffset >= TimeSpan.Zero ? "+" : "-") + tz.BaseUtcOffset.ToString(@"hh\:mm") + }) + .OrderBy(tz => tz.offsetMinutes); + + return Results.Ok(zones); }); } } diff --git a/backend/src/Modules/Conversations/Application/Abstractions/IUserStatusService.cs b/backend/src/Modules/Conversations/Application/Abstractions/IUserStatusService.cs new file mode 100644 index 0000000..d4a8761 --- /dev/null +++ b/backend/src/Modules/Conversations/Application/Abstractions/IUserStatusService.cs @@ -0,0 +1,8 @@ +using System; + +namespace Knot.Modules.Conversations.Application.Abstractions; + +public interface IUserStatusService +{ + bool IsUserOnline(string userId); +} diff --git a/backend/src/Modules/Conversations/DependencyInjection.cs b/backend/src/Modules/Conversations/DependencyInjection.cs index cca4e06..14d4cef 100644 --- a/backend/src/Modules/Conversations/DependencyInjection.cs +++ b/backend/src/Modules/Conversations/DependencyInjection.cs @@ -48,6 +48,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } } \ No newline at end of file diff --git a/backend/src/Modules/Conversations/Infrastructure/Services/UserStatusService.cs b/backend/src/Modules/Conversations/Infrastructure/Services/UserStatusService.cs new file mode 100644 index 0000000..aa59627 --- /dev/null +++ b/backend/src/Modules/Conversations/Infrastructure/Services/UserStatusService.cs @@ -0,0 +1,12 @@ +using Knot.Modules.Conversations.Application.Abstractions; +using Knot.Modules.Conversations.Infrastructure.SignalR; + +namespace Knot.Modules.Conversations.Infrastructure.Services; + +public sealed class UserStatusService : IUserStatusService +{ + public bool IsUserOnline(string userId) + { + return ChatHub.IsUserOnline(userId); + } +} diff --git a/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs b/backend/src/Modules/Klipy/Application/Abstractions/IKlipyClient.cs similarity index 86% rename from backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs rename to backend/src/Modules/Klipy/Application/Abstractions/IKlipyClient.cs index cc6da27..069d486 100644 --- a/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs +++ b/backend/src/Modules/Klipy/Application/Abstractions/IKlipyClient.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -namespace Knot.Modules.Stories.Application.Abstractions; +namespace Knot.Modules.Klipy.Application.Abstractions; public interface IKlipyClient { diff --git a/backend/src/Modules/Klipy/DependencyInjection.cs b/backend/src/Modules/Klipy/DependencyInjection.cs index 766c801..c6f5e20 100644 --- a/backend/src/Modules/Klipy/DependencyInjection.cs +++ b/backend/src/Modules/Klipy/DependencyInjection.cs @@ -1,7 +1,12 @@ -namespace Knot.Modules.Klipy; using Microsoft.Extensions.DependencyInjection; +using Knot.Modules.Klipy.Application.Abstractions; +using Knot.Modules.Klipy.Infrastructure.External; + +namespace Knot.Modules.Klipy; + public static class DependencyInjection { public static IServiceCollection AddKlipyModule(this IServiceCollection services) { + services.AddHttpClient(); return services; } } \ No newline at end of file diff --git a/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs b/backend/src/Modules/Klipy/Infrastructure/External/KlipyClient.cs similarity index 91% rename from backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs rename to backend/src/Modules/Klipy/Infrastructure/External/KlipyClient.cs index 676ffcd..74e6fac 100644 --- a/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs +++ b/backend/src/Modules/Klipy/Infrastructure/External/KlipyClient.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Generic; using System.Net.Http; -using Knot.Modules.Stories.Application.Abstractions; using System.Threading; using System.Threading.Tasks; +using Knot.Modules.Klipy.Application.Abstractions; -namespace Knot.Modules.Stories.Infrastructure.External; +namespace Knot.Modules.Klipy.Infrastructure.External; public sealed class KlipyClient : IKlipyClient { diff --git a/backend/src/Modules/Klipy/Knot.Modules.Klipy.csproj b/backend/src/Modules/Klipy/Knot.Modules.Klipy.csproj index 9afda16..c86d011 100644 --- a/backend/src/Modules/Klipy/Knot.Modules.Klipy.csproj +++ b/backend/src/Modules/Klipy/Knot.Modules.Klipy.csproj @@ -7,13 +7,9 @@ - - - + - - diff --git a/backend/src/Modules/Messaging/Application/Abstractions/IUserStatsService.cs b/backend/src/Modules/Messaging/Application/Abstractions/IUserStatsService.cs new file mode 100644 index 0000000..e7a6c7f --- /dev/null +++ b/backend/src/Modules/Messaging/Application/Abstractions/IUserStatsService.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Knot.Modules.Messaging.Application.Abstractions; + +public record UserStats(int MessageCount, long StorageSize); + +public interface IUserStatsService +{ + Task> GetStatsForUsersAsync(IEnumerable userIds, CancellationToken ct = default); + Task GetTotalStorageSizeAsync(CancellationToken ct = default); + Task GetCountOrphanedMessagesAsync(HashSet activeChatIds, CancellationToken ct = default); + Task GetOrphanedMediaSizeAsync(HashSet validFileIds, CancellationToken ct = default); +} diff --git a/backend/src/Modules/Messaging/DependencyInjection.cs b/backend/src/Modules/Messaging/DependencyInjection.cs index 4c0401b..c68fe1e 100644 --- a/backend/src/Modules/Messaging/DependencyInjection.cs +++ b/backend/src/Modules/Messaging/DependencyInjection.cs @@ -36,6 +36,7 @@ public static class DependencyInjection // Registration services.AddScoped(); services.AddScoped(); + services.AddScoped(); // MediatR services.AddMediatR(config => diff --git a/backend/src/Modules/Messaging/Domain/MediaMessage.cs b/backend/src/Modules/Messaging/Domain/MediaMessage.cs index e250327..130b520 100644 --- a/backend/src/Modules/Messaging/Domain/MediaMessage.cs +++ b/backend/src/Modules/Messaging/Domain/MediaMessage.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using MongoDB.Bson.Serialization.Attributes; namespace Knot.Modules.Messaging.Domain; +[BsonDiscriminator("MediaMessage")] public class MediaMessage : Message { public override string Type => MediaType.ToString().ToLower(); diff --git a/backend/src/Modules/Messaging/Domain/Message.cs b/backend/src/Modules/Messaging/Domain/Message.cs index 1f8db94..78c9f6e 100644 --- a/backend/src/Modules/Messaging/Domain/Message.cs +++ b/backend/src/Modules/Messaging/Domain/Message.cs @@ -2,11 +2,15 @@ using System; using System.Collections.Generic; using Knot.Shared.Kernel; +using MongoDB.Bson.Serialization.Attributes; + namespace Knot.Modules.Messaging.Domain; /// /// Абстрактная база агрегата Сообщение. /// +[BsonDiscriminator(RootClass = true)] +[BsonKnownTypes(typeof(TextMessage), typeof(MediaMessage), typeof(StoryMessage), typeof(PollMessage))] public abstract class Message : AggregateRoot { // ================== Базовые поля ================== diff --git a/backend/src/Modules/Messaging/Domain/PollMessage.cs b/backend/src/Modules/Messaging/Domain/PollMessage.cs index da5b2a8..5eabb8b 100644 --- a/backend/src/Modules/Messaging/Domain/PollMessage.cs +++ b/backend/src/Modules/Messaging/Domain/PollMessage.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using MongoDB.Bson.Serialization.Attributes; namespace Knot.Modules.Messaging.Domain; +[BsonDiscriminator("PollMessage")] public class PollMessage : Message { public override string Type => "poll"; diff --git a/backend/src/Modules/Messaging/Domain/StoryMessage.cs b/backend/src/Modules/Messaging/Domain/StoryMessage.cs index f04acfc..b7d21c1 100644 --- a/backend/src/Modules/Messaging/Domain/StoryMessage.cs +++ b/backend/src/Modules/Messaging/Domain/StoryMessage.cs @@ -1,7 +1,9 @@ using System; +using MongoDB.Bson.Serialization.Attributes; namespace Knot.Modules.Messaging.Domain; +[BsonDiscriminator("StoryMessage")] public class StoryMessage : Message { public override string Type => "story"; diff --git a/backend/src/Modules/Messaging/Domain/TextMessage.cs b/backend/src/Modules/Messaging/Domain/TextMessage.cs index 554de0b..0ff7450 100644 --- a/backend/src/Modules/Messaging/Domain/TextMessage.cs +++ b/backend/src/Modules/Messaging/Domain/TextMessage.cs @@ -1,7 +1,9 @@ using System; +using MongoDB.Bson.Serialization.Attributes; namespace Knot.Modules.Messaging.Domain; +[BsonDiscriminator("TextMessage")] public class TextMessage : Message { public override string Type => "text"; diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs index 5a13865..3e533c4 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs @@ -14,9 +14,9 @@ public static class MongoDbMapConfigurator { if (_initialized) return; - BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); + try { BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); } catch { /* Already registered */ } - BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); + BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.Int32)); BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); BsonClassMap.RegisterClassMap>(cm => diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs new file mode 100644 index 0000000..51112c4 --- /dev/null +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/UserStatsService.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Knot.Modules.Messaging.Application.Abstractions; +using Knot.Modules.Messaging.Domain; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo; + +public sealed class UserStatsService : IUserStatsService +{ + private readonly IMongoCollection _messages; + + public UserStatsService(IMongoDatabase database) + { + _messages = database.GetCollection("messages"); + } + + public async Task> GetStatsForUsersAsync(IEnumerable userIds, CancellationToken ct = default) + { + var userGuidList = userIds.ToList(); + if (!userGuidList.Any()) return new Dictionary(); + + // Эффективная агрегация: считаем количество и сумму Media.Size + var stats = await _messages.Aggregate() + .Match(Builders.Filter.In(m => m.SenderId, userGuidList)) + .Group(new BsonDocument { + { "_id", "$SenderId" }, + { "Count", new BsonDocument("$sum", 1) }, + { "MediaSize", new BsonDocument("$sum", new BsonDocument("$sum", "$Media.Size")) } + }) + .ToListAsync(ct); + + return stats.ToDictionary( + doc => doc["_id"].AsGuid, + doc => new UserStats( + doc["Count"].AsInt32, + doc.Contains("MediaSize") && !doc["MediaSize"].IsBsonNull ? (long)(doc["MediaSize"].IsInt64 ? doc["MediaSize"].AsInt64 : doc["MediaSize"].AsInt32) : 0L + ) + ); + } + + public async Task GetTotalStorageSizeAsync(CancellationToken ct = default) + { + var result = await _messages.Aggregate() + .Group(new BsonDocument { + { "_id", BsonNull.Value }, + { "TotalSize", new BsonDocument("$sum", new BsonDocument("$sum", "$Media.Size")) } + }) + .FirstOrDefaultAsync(ct); + + if (result == null) return 0; + return result.Contains("TotalSize") ? (long)(result["TotalSize"].IsInt64 ? result["TotalSize"].AsInt64 : result["TotalSize"].AsInt32) : 0L; + } + + public async Task GetCountOrphanedMessagesAsync(HashSet activeChatIds, CancellationToken ct = default) + { + var filter = Builders.Filter.Or( + Builders.Filter.BitsAnySet(m => m.State, (long)MessageState.IsDeleted), + Builders.Filter.Nin(m => m.ChatId, activeChatIds) + ); + return (int)(await _messages.CountDocumentsAsync(filter, cancellationToken: ct)); + } + + public async Task GetOrphanedMediaSizeAsync(HashSet validFileIds, CancellationToken ct = default) + { + // Для больших объемов правильнее собирать список ВСЕХ URL файлов из сообщений, + // но здесь мы оптимизируем через проекцию, чтобы вернуть только нужные поля. + // Этот метод может быть реализован в BackgroundTask для очень больших баз. + return 0; // Временная заглушка, реальный подсчет через курсор в DryRun + } +} diff --git a/backend/src/Modules/Settings/Application/Settings/DTOs/PublicConfigDto.cs b/backend/src/Modules/Settings/Application/Settings/DTOs/PublicConfigDto.cs index 7fc200a..5291080 100644 --- a/backend/src/Modules/Settings/Application/Settings/DTOs/PublicConfigDto.cs +++ b/backend/src/Modules/Settings/Application/Settings/DTOs/PublicConfigDto.cs @@ -37,6 +37,7 @@ public record PublicConfigDto { SupportGroups = settings.Chats.SupportGroups, MaxGroupParticipants = settings.Chats.MaxGroupParticipants, + EnableAutoClean = settings.Chats.EnableAutoClean, AllowChatToGroupConversion = settings.Chats.AllowChatToGroupConversion, EnableFolders = settings.Chats.EnableFolders }, @@ -61,7 +62,6 @@ public record PublicConfigDto WebRtc = new WebRtcConfigDto { Enabled = settings.WebRtc.Enabled, - EnableVoiceCalls = settings.WebRtc.EnableVoiceCalls, EnableVideoCalls = settings.WebRtc.EnableVideoCalls, EnableScreenSharing = settings.WebRtc.EnableScreenSharing, TurnHost = settings.WebRtc.TurnHost, @@ -107,6 +107,7 @@ public record ChatsConfigDto { public bool SupportGroups { get; init; } public int MaxGroupParticipants { get; init; } + public bool EnableAutoClean { get; init; } public bool AllowChatToGroupConversion { get; init; } public bool EnableFolders { get; init; } } @@ -133,7 +134,6 @@ public record MessagesConfigDto public record WebRtcConfigDto { public bool Enabled { get; init; } - public bool EnableVoiceCalls { get; init; } public bool EnableVideoCalls { get; init; } public bool EnableScreenSharing { get; init; } public string TurnHost { get; init; } = string.Empty; diff --git a/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs b/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs index 3bd43cd..96d6334 100644 --- a/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs +++ b/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs @@ -25,7 +25,7 @@ public class ChatsConfig { public bool SupportGroups { get; set; } = true; public int MaxGroupParticipants { get; set; } = 200000; - public bool AutoCleanChats { get; set; } = false; + public bool EnableAutoClean { get; set; } = false; // Возможность автоочистки для пользователей public bool AllowChatToGroupConversion { get; set; } = true; public bool EnableFolders { get; set; } = true; } @@ -53,7 +53,6 @@ public class MessagesConfig public class WebRtcConfig { public bool Enabled { get; set; } = false; - public bool EnableVoiceCalls { get; set; } = true; public bool EnableVideoCalls { get; set; } = true; public bool EnableScreenSharing { get; set; } = true; public string TurnHost { get; set; } = string.Empty; diff --git a/backend/src/Modules/Storage/Infrastructure/Storage/S3FileStorageService.cs b/backend/src/Modules/Storage/Infrastructure/Storage/S3FileStorageService.cs index 0942a24..b065675 100644 --- a/backend/src/Modules/Storage/Infrastructure/Storage/S3FileStorageService.cs +++ b/backend/src/Modules/Storage/Infrastructure/Storage/S3FileStorageService.cs @@ -205,11 +205,15 @@ public class S3FileStorageService : IFileStorageService var result = new List<(string FileId, long Size)>(); try { + await EnsureBucketExistsAsync(); var listArgs = new ListObjectsArgs().WithBucket(_bucketName).WithRecursive(true); await foreach (var item in _minioClient.ListObjectsEnumAsync(listArgs).ConfigureAwait(false)) { - result.Add((item.Key, (long)item.Size)); + if (item != null) + { + result.Add((item.Key, (long)item.Size)); + } } } catch (Exception ex) diff --git a/backend/src/Modules/Stories/DependencyInjection.cs b/backend/src/Modules/Stories/DependencyInjection.cs index 32270e5..8a599f3 100644 --- a/backend/src/Modules/Stories/DependencyInjection.cs +++ b/backend/src/Modules/Stories/DependencyInjection.cs @@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Knot.Modules.Stories.Application.Abstractions; -using Knot.Modules.Stories.Infrastructure.External; using Knot.Modules.Stories.Infrastructure.Persistence.Mongo; using MongoDB.Driver; using System.Net.Http; @@ -14,7 +13,6 @@ public static class DependencyInjection public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration) { services.AddScoped(); - services.AddHttpClient(); var connectionString = configuration.GetConnectionString("DefaultConnection"); services.AddDbContext(options => diff --git a/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoriesMongoMapConfigurator.cs b/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoriesMongoMapConfigurator.cs index 59a45b9..153ed4c 100644 --- a/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoriesMongoMapConfigurator.cs +++ b/backend/src/Modules/Stories/Infrastructure/Persistence/Mongo/StoriesMongoMapConfigurator.cs @@ -13,7 +13,7 @@ public static class StoriesMongoMapConfigurator { if (_configured) return; - BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); + try { BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); } catch { /* Already registered */ } BsonClassMap.RegisterClassMap(cm => { diff --git a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx index 96a5fce..06f86ab 100644 --- a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx +++ b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx @@ -47,7 +47,7 @@ interface StoriesConfig { interface ChatsConfig { supportGroups: boolean; maxGroupParticipants: number; - autoCleanChats: boolean; + enableAutoClean: boolean; allowChatToGroupConversion: boolean; enableFolders: boolean; } @@ -73,7 +73,6 @@ interface MessagesConfig { interface WebRtcConfig { enabled: boolean; - enableVoiceCalls: boolean; enableVideoCalls: boolean; enableScreenSharing: boolean; turnHost: string; @@ -82,6 +81,14 @@ interface WebRtcConfig { turnSecret: string; } +interface TimezoneDto { + id: string; + displayName: string; + standardName: string; + offsetMinutes: number; + offsetString: string; +} + interface KlipyConfig { enabled: boolean; apiKey: string; @@ -124,6 +131,8 @@ interface AppUser { lastOnlineAt: string; isOnline?: boolean; isBanned?: boolean; + messageCount?: number; + storageSize?: number; stats?: { messagesCount: number; mediaCount: number; @@ -178,7 +187,9 @@ const translations = { mediaSent: 'Media', filesSent: 'Files', linksSent: 'Links', - userStorageOccupied: 'Storage', + userStorageOccupied: 'Attachments', + sent: 'Messages', + storage: 'Attachments', displayName: 'Display Name', cancel: 'Cancel', createUser: 'Create User', @@ -265,7 +276,7 @@ const translations = { maxMediaSize: 'Max file size for story media.', supportGroups: 'Enable group chat functionality.', maxGroupMembers: 'Max participants per group.', - autoClean: 'Automatically remove old messages.', + autoClean: 'Enable/disable auto-cleanup feature for users. Users manage their own chat timers.', chatToGroup: 'Allow upgrading 1-on-1 chats to groups.', enableFolders: 'Allow users to use chat folders.', dailyLimit: 'Max messages per user day (0 = unlimited).', @@ -273,7 +284,7 @@ const translations = { maxFileSize: 'Max size for message attachments.', noCopy: 'Prevent text copying in clients.', links: 'Make URLs clickable.', - webRtc: 'Required for real-time calls.', + webRtc: 'Enable real-time audio and video calls. This is a master switch for the WebRTC module.', turn: 'Required for calls behind NAT.', klipy: 'Integration for stickers and GIFs.', federation: 'Communication between different Knot instances.', @@ -343,7 +354,9 @@ const translations = { mediaSent: 'Медиа', filesSent: 'Файлы', linksSent: 'Ссылки', - userStorageOccupied: 'Хранилище', + userStorageOccupied: 'Вложения', + sent: 'Сообщения', + storage: 'Вложения', displayName: 'Имя', cancel: 'Отмена', createUser: 'Создать', @@ -430,7 +443,7 @@ const translations = { maxMediaSize: 'Лимит одного файла в историях.', supportGroups: 'Включить группы.', maxGroupMembers: 'Максимум людей в одной группе.', - autoClean: 'Удаление старых сообщений.', + autoClean: 'Разрешить пользователям использовать функцию автоочистки. Самим процессом (таймерами) управляют пользователи в своих чатах.', chatToGroup: 'Разрешить создавать группы из чатов.', enableFolders: 'Разрешить папки чатов.', dailyLimit: 'Лимит сообщений в сутки (0 = без лимита).', @@ -438,7 +451,7 @@ const translations = { maxFileSize: 'Лимит файлов в сообщениях.', noCopy: 'Мешать копированию текста.', links: 'Автоматические ссылки.', - webRtc: 'Нужно для звонков.', + webRtc: 'Включить возможность аудио и видео звонков. Глобальный переключатель для модуля WebRTC.', turn: 'Нужно для звонков за NAT.', klipy: 'Стикеры и GIF.', federation: 'Связь с другими серверами Knot.', @@ -487,9 +500,11 @@ export default function AdminPage() { const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; - const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const k = 1024; + const dm = 2; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; }; const bytesToMb = (bytes: number) => Math.floor(bytes / (1024 * 1024)); @@ -522,6 +537,10 @@ export default function AdminPage() { const [newUser, setNewUser] = useState({ username: '', displayName: '', password: '' }); const [generatedPass, setGeneratedPass] = useState(''); + const [timezones, setTimezones] = useState([]); + const [tzSearch, setTzSearch] = useState(''); + const [showTzDropdown, setShowTzDropdown] = useState(false); + const [toast, setToast] = useState<{message: string, type: 'success' | 'error'} | null>(null); const showToast = (message: string, type: 'success' | 'error' = 'success') => { @@ -616,6 +635,13 @@ export default function AdminPage() { } catch {} }; + const fetchTimezones = async () => { + try { + const res = await httpClient.request('/admin/timezones'); + setTimezones(res); + } catch {} + }; + const saveSettings = async () => { if (!config) return; try { @@ -786,6 +812,7 @@ export default function AdminPage() { setAuthenticated(true); fetchDashboard(); fetchSettings(); + fetchTimezones(); searchUsers(''); } catch { showToast(t.errorInvalidLogin, 'error'); @@ -931,11 +958,11 @@ export default function AdminPage() { ) : (
-
+
{t.orphanedMessages}
{cleanStats.orphanedMessagesCount}
-
+
{t.orphanedMedia}
{formatBytes(cleanStats.orphanedMediaBytes)}
@@ -987,11 +1014,56 @@ export default function AdminPage() {
{t.hints.enableRegistration}
-
)} @@ -1060,7 +1132,7 @@ export default function AdminPage() {
{t.autoClean} - setConfig({...config, chats: {...config.chats, autoCleanChats: v}})} /> + setConfig({...config, chats: {...config.chats, enableAutoClean: v}})} />
{t.hints.autoClean}
@@ -1144,16 +1216,8 @@ export default function AdminPage() { {t.hints.webRtc} - {config.webRtc.enabled && ( + {config.webRtc.enabled && (<>
-
-
-
- {t.voiceCalls} - setConfig({...config, webRtc: {...config.webRtc, enableVoiceCalls: v}})} /> -
- {t.hints.voiceCalls} -
{t.videoCalls} @@ -1204,8 +1268,7 @@ export default function AdminPage() { {t.testConnection}
-
- )} + )}
)} @@ -1344,9 +1407,20 @@ export default function AdminPage() { {u.isOnline ? ( {t.online.toUpperCase()} ) : ( - {t.offline} + {t.offline} )}
+ +
+
+ {t.sent} + {u.messageCount || 0} +
+
+ {t.storage} + {formatBytes(u.storageSize || 0)} +
+
))}