This commit is contained in:
Халимов Рустам
2026-03-29 14:26:14 +03:00
parent 9bae9752cc
commit 22bc964f27
29 changed files with 405 additions and 130 deletions

View File

@@ -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<Knot.Shared.Infrastructure.Persistence.SystemDbContext>();
await systemDb.Database.MigrateAsync();
var storiesDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Stories.Infrastructure.Database.StoriesDbContext>();
await storiesDb.Database.MigrateAsync();
// Set Encryption Service for MongoDB serializers
var encryptionService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Security.IEncryptionService>();
Knot.Modules.Messaging.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService;

View File

@@ -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;

View File

@@ -11,5 +11,7 @@ public record AdminUserDto(
DateTime CreatedAt,
bool IsOnline,
DateTime LastOnlineAt,
bool IsBanned
bool IsBanned,
int? MessageCount = 0,
long? StorageSize = 0
);

View File

@@ -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<CleanDryRunQuery,
_identityDb = identityDb;
}
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
public async Task<Result<CleanupDryRunResultDto>> 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<string>();
// 2. Считаем сообщения подлежащие удалению (MongoDB)
var orphanedFilter = Builders<Message>.Filter.Or(
Builders<Message>.Filter.BitsAnySet(m => m.State, (long)MessageState.IsDeleted),
Builders<Message>.Filter.Nin(m => m.ChatId, activeChatIds)
);
var orphanedMessagesCount = await _messages.CountDocumentsAsync(orphanedFilter, cancellationToken: ct);
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
// 3. Собираем ID всех используемых файлов
var validFileIds = new HashSet<string>();
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<Message>.Filter.And(
Builders<Message>.Filter.BitsAllClear(m => m.State, (long)MessageState.IsDeleted),
Builders<Message>.Filter.In(m => m.ChatId, activeChatIds)
);
var projection = Builders<Message>.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<CleanupDryRunResultDto>(new Error("Cleanup.ProcessError", $"Failed to analyze junk data: {ex.Message}"));
return Result.Failure<CleanupDryRunResultDto>(new Error("Cleanup.Error", $"Ошибка при анализе данных: {ex.Message}"));
}
}
private void AddFileIdIfValid(string? url, HashSet<string> 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);
}
}
}
}

View File

@@ -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<List<AdminUserDto>>;
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<AdminUserDto>>
{
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<Result<List<AdminUserDto>>> Handle(SearchUsersQuery request, CancellationToken cancellationToken)
public async Task<Result<List<AdminUserDto>>> 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<AdminUserDto>());
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<List<AdminUserDto>>(new Error("Admin.SearchUsers.Error", $"Ошибка при поиске пользователей: {ex.Message}"));
}
}
}

View File

@@ -10,6 +10,7 @@
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
<ProjectReference Include="..\Klipy\Knot.Modules.Klipy.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />

View File

@@ -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);
});
}
}

View File

@@ -0,0 +1,8 @@
using System;
namespace Knot.Modules.Conversations.Application.Abstractions;
public interface IUserStatusService
{
bool IsUserOnline(string userId);
}

View File

@@ -48,6 +48,7 @@ public static class DependencyInjection
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
services.AddScoped<Knot.Modules.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
services.AddScoped<IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
return services;
}
}

View File

@@ -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);
}
}

View File

@@ -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
{

View File

@@ -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<IKlipyClient, KlipyClient>();
return services;
}
}

View File

@@ -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
{

View File

@@ -7,13 +7,9 @@
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<Dictionary<Guid, UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default);
Task<long> GetTotalStorageSizeAsync(CancellationToken ct = default);
Task<int> GetCountOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken ct = default);
Task<long> GetOrphanedMediaSizeAsync(HashSet<string> validFileIds, CancellationToken ct = default);
}

View File

@@ -36,6 +36,7 @@ public static class DependencyInjection
// Registration
services.AddScoped<IMessageRepository, MessageRepository>();
services.AddScoped<IMessageReactionRepository, MessageReactionRepository>();
services.AddScoped<IUserStatsService, UserStatsService>();
// MediatR
services.AddMediatR(config =>

View File

@@ -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();

View File

@@ -2,11 +2,15 @@ using System;
using System.Collections.Generic;
using Knot.Shared.Kernel;
using MongoDB.Bson.Serialization.Attributes;
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Абстрактная база агрегата Сообщение.
/// </summary>
[BsonDiscriminator(RootClass = true)]
[BsonKnownTypes(typeof(TextMessage), typeof(MediaMessage), typeof(StoryMessage), typeof(PollMessage))]
public abstract class Message : AggregateRoot<Guid>
{
// ================== Базовые поля ==================

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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<MessageState>(BsonType.String));
BsonSerializer.RegisterSerializer(new EnumSerializer<MessageState>(BsonType.Int32));
BsonSerializer.RegisterSerializer(new EnumSerializer<MediaType>(BsonType.String));
BsonClassMap.RegisterClassMap<Entity<Guid>>(cm =>

View File

@@ -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<Message> _messages;
public UserStatsService(IMongoDatabase database)
{
_messages = database.GetCollection<Message>("messages");
}
public async Task<Dictionary<Guid, UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
{
var userGuidList = userIds.ToList();
if (!userGuidList.Any()) return new Dictionary<Guid, UserStats>();
// Эффективная агрегация: считаем количество и сумму Media.Size
var stats = await _messages.Aggregate()
.Match(Builders<Message>.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<long> 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<int> GetCountOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken ct = default)
{
var filter = Builders<Message>.Filter.Or(
Builders<Message>.Filter.BitsAnySet(m => m.State, (long)MessageState.IsDeleted),
Builders<Message>.Filter.Nin(m => m.ChatId, activeChatIds)
);
return (int)(await _messages.CountDocumentsAsync(filter, cancellationToken: ct));
}
public async Task<long> GetOrphanedMediaSizeAsync(HashSet<string> validFileIds, CancellationToken ct = default)
{
// Для больших объемов правильнее собирать список ВСЕХ URL файлов из сообщений,
// но здесь мы оптимизируем через проекцию, чтобы вернуть только нужные поля.
// Этот метод может быть реализован в BackgroundTask для очень больших баз.
return 0; // Временная заглушка, реальный подсчет через курсор в DryRun
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)

View File

@@ -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<IStoryRepository, StoryRepository>();
services.AddHttpClient<IKlipyClient, KlipyClient>();
var connectionString = configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<Infrastructure.Database.StoriesDbContext>(options =>

View File

@@ -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<Story>(cm =>
{