Билд
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -11,5 +11,7 @@ public record AdminUserDto(
|
||||
DateTime CreatedAt,
|
||||
bool IsOnline,
|
||||
DateTime LastOnlineAt,
|
||||
bool IsBanned
|
||||
bool IsBanned,
|
||||
int? MessageCount = 0,
|
||||
long? StorageSize = 0
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user