Перепиливание под чистый DDD

This commit is contained in:
Халимов Рустам
2026-03-22 23:59:33 +03:00
parent 5da1a2f45d
commit 6e532b021d
302 changed files with 3595 additions and 3679 deletions

View File

@@ -0,0 +1,102 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
namespace Host.Application.Admin.Commands;
public record CleanRunCommand(IFileStorageService FileStorage, AuthDbContext IdentityDb) : ICommand<MessageResponse>;
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages");
}
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
{
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 orphanMessages = allMessages
.Where(m => !activeChatIds.Contains(m.ChatId))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
foreach (var u in activeMessageUrls)
{
validUrls.Add(u!);
}
foreach (var u in activeChatUrls)
{
validUrls.Add(u);
}
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
await request.FileStorage.DeleteFileAsync(file.FileId);
}
}
if (orphanMessages.Any())
{
var orphanIds = orphanMessages.Select(m => m.Id).ToList();
var filter = Builders<Message>.Filter.In(m => m.Id, orphanIds);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));
}
}

View File

@@ -0,0 +1,47 @@
using Knot.Modules.Auth.Domain;
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
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.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Application.Abstractions;
namespace Host.Application.Admin.Commands;
public record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand<SuccessResponse>;
internal sealed class ResetUserPasswordCommandHandler : ICommandHandler<ResetUserPasswordCommand, SuccessResponse>
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _identityUnitOfWork;
public ResetUserPasswordCommandHandler(IUserRepository userRepository, IAuthUnitOfWork identityUnitOfWork)
{
_userRepository = userRepository;
_identityUnitOfWork = identityUnitOfWork;
}
public async Task<Result<SuccessResponse>> Handle(ResetUserPasswordCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<SuccessResponse>(AuthErrors.UserNotFound);
}
if (string.IsNullOrWhiteSpace(request.NewPassword))
return Result.Failure<SuccessResponse>(DomainErrors.InvalidPassword);
var hash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
user.ChangePassword(hash);
await _identityUnitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -0,0 +1,27 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
namespace Host.Application.Admin.Commands;
public record UpdateSettingsCommand(SystemSettingsDto Settings) : ICommand<SystemSettingsDto>;
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, SystemSettingsDto>
{
private readonly ISettingsService _settingsService;
public UpdateSettingsCommandHandler(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public async Task<Result<SystemSettingsDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
{
await _settingsService.UpdateSettingsAsync(request.Settings, cancellationToken);
return Result.Success(request.Settings);
}
}

View File

@@ -0,0 +1,15 @@
using System;
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public record AdminUserDetailsDto(
Guid Id,
string Username,
string DisplayName,
string? Bio,
string? Avatar,
DateTime CreatedAt,
bool IsOnline,
DateTime LastOnlineAt,
AdminUserStatsDto Stats
);

View File

@@ -0,0 +1,14 @@
using System;
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public record AdminUserDto(
Guid Id,
string Username,
string DisplayName,
string? Email,
string? Avatar,
DateTime CreatedAt,
bool IsOnline,
DateTime LastOnlineAt
);

View File

@@ -0,0 +1,9 @@
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public record AdminUserStatsDto(
int MessagesCount,
int MediaCount,
int FilesCount,
int LinksCount,
long StorageUsedBytes
);

View File

@@ -0,0 +1,7 @@
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public class CleanupDryRunResultDto
{
public int OrphanedMessagesCount { get; set; }
public long OrphanedMediaBytes { get; set; }
}

View File

@@ -0,0 +1,105 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
using Microsoft.EntityFrameworkCore;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Application.Users;
namespace Host.Application.Admin.Queries;
public record CleanDryRunQuery(IFileStorageService FileStorage, AuthDbContext IdentityDb) : IQuery<CleanupDryRunResultDto>;
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages");
}
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
{
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))
.ToList();
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId))
.ToList();
var orphanedMessagesCount = orphanedMessages.Count;
var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList();
var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
var activeChatUrls = allChats
.Where(c => !string.IsNullOrEmpty(c.Avatar))
.Select(c => c.Avatar!);
var activeUserUrls = allUsers
.Where(u => !string.IsNullOrEmpty(u.Avatar))
.Select(u => u.Avatar!);
foreach (var u in activeMessageUrls)
{
validUrls.Add(u!);
}
foreach (var u in activeChatUrls)
{
validUrls.Add(u);
}
foreach (var u in activeUserUrls)
{
validUrls.Add(u);
}
var validFileIds = validUrls
.Where(u => u.Contains("/api/files/"))
.Select(u => u.Split('/').Last())
.ToHashSet();
long safeBytes = 0;
foreach (var file in allMinioFiles)
{
if (!validFileIds.Contains(file.FileId))
{
safeBytes += file.Size;
}
}
return Result.Success(new CleanupDryRunResultDto
{
OrphanedMessagesCount = orphanedMessagesCount,
OrphanedMediaBytes = safeBytes
});
}
}

View File

@@ -0,0 +1,26 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Services;
namespace Host.Application.Admin.Queries;
public record GetDashboardStatsQuery() : IQuery<DashboardStatsDto>;
internal sealed class GetDashboardStatsQueryHandler : IQueryHandler<GetDashboardStatsQuery, DashboardStatsDto>
{
private readonly IStatisticsService _statisticsService;
public GetDashboardStatsQueryHandler(IStatisticsService statisticsService)
{
_statisticsService = statisticsService;
}
public async Task<Result<DashboardStatsDto>> Handle(GetDashboardStatsQuery request, CancellationToken cancellationToken)
{
var stats = await _statisticsService.GetDashboardStatsAsync(cancellationToken);
return Result.Success(stats);
}
}

View File

@@ -0,0 +1,27 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
namespace Host.Application.Admin.Queries;
public record GetSettingsQuery() : IQuery<SystemSettingsDto>;
internal sealed class GetSettingsQueryHandler : IQueryHandler<GetSettingsQuery, SystemSettingsDto>
{
private readonly ISettingsService _settingsService;
public GetSettingsQueryHandler(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public async Task<Result<SystemSettingsDto>> Handle(GetSettingsQuery request, CancellationToken cancellationToken)
{
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
return Result.Success(settings);
}
}

View File

@@ -0,0 +1,77 @@
using Knot.Modules.Auth.Domain;
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System;
using System.Linq;
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.Conversations.Domain;
using MongoDB.Driver;
namespace Host.Application.Admin.Queries;
public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDetailsDto>;
internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDetailsDto>
{
private readonly IUserRepository _userRepository;
private readonly IMongoCollection<Message> _messages;
public GetUserDetailsQueryHandler(IUserRepository userRepository, IMongoDatabase mongoDatabase)
{
_userRepository = userRepository;
_messages = mongoDatabase.GetCollection<Message>("Messages");
}
public async Task<Result<AdminUserDetailsDto>> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken)
{
var targetUser = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (targetUser == null)
{
return Result.Failure<AdminUserDetailsDto>(AuthErrors.UserNotFound);
}
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
var userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
var messagesCount = userMessages.Count;
var allUserMedia = userMessages.OfType<MediaMessage>().SelectMany(m => m.Media).ToList();
var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video");
var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio");
var storageUsed = allUserMedia.Sum(m => m.Size ?? 0);
var userContents = userMessages.OfType<TextMessage>().Select(m => m.Content)
.Concat(userMessages.OfType<MediaMessage>().Where(m => m.Caption != null).Select(m => m.Caption))
.ToList();
var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http"));
var result = new AdminUserDetailsDto(
targetUser.Id,
targetUser.Username,
targetUser.DisplayName,
targetUser.Bio,
targetUser.Avatar,
targetUser.CreatedAt,
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()),
Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt,
new AdminUserStatsDto(
messagesCount,
mediaCount,
filesCount,
linksCount,
storageUsed
)
);
return Result.Success(result);
}
}

View File

@@ -0,0 +1,46 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Collections.Generic;
using System.Linq;
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;
namespace Host.Application.Admin.Queries;
public record SearchUsersQuery(string Query) : IQuery<List<AdminUserDto>>;
internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, List<AdminUserDto>>
{
private readonly IUserRepository _userRepository;
public SearchUsersQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<Result<List<AdminUserDto>>> Handle(SearchUsersQuery request, CancellationToken cancellationToken)
{
var users = string.IsNullOrWhiteSpace(request.Query)
? await _userRepository.SearchUsersAsync("", cancellationToken)
: await _userRepository.SearchUsersAsync(request.Query, cancellationToken);
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
)).ToList();
return Result.Success(result);
}
}

View File

@@ -0,0 +1,7 @@
namespace Knot.Modules.Admin;
using Microsoft.Extensions.DependencyInjection;
public static class DependencyInjection {
public static IServiceCollection AddAdminModule(this IServiceCollection services) {
return services;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<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" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>Knot.Modules.Admin.UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,86 @@
using Carter;
using Knot.Modules.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Configuration;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Auth.Infrastructure.Persistence;
using MediatR;
using Host.Application.Admin.Queries;
using Host.Application.Admin.Commands;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Host.Presentation.Endpoints;
public sealed class AdminEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/admin"); // Middleware handles auth
group.MapGet("settings", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetSettingsQuery(), ct);
return Results.Ok(result.Value);
});
group.MapPut("settings", async ([FromBody] SystemSettingsDto settings, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new UpdateSettingsCommand(settings), ct);
return Results.Ok(result.Value);
});
group.MapGet("dashboard", async (ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetDashboardStatsQuery(), ct);
return Results.Ok(result.Value);
});
group.MapPost("users", async ([FromBody] RegisterUserCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
if (result.IsFailure) return Results.BadRequest(new { error = result.Error.Description });
var userDetails = await sender.Send(new GetUserDetailsQuery(result.Value.User.Id), ct);
return Results.Ok(userDetails.Value);
});
group.MapPost("users/{userId:guid}/reset-password", async (Guid userId, [FromBody] ResetPasswordDto dto, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
if (result.IsFailure)
{
if (result.Error.Code == "User.NotFound") return Results.NotFound(new { error = "User not found" });
return Results.BadRequest(new { error = result.Error.Description });
}
return Results.Ok(result.Value);
});
group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) =>
{
var result = await sender.Send(new SearchUsersQuery(query), ct);
return Results.Ok(result.Value);
});
group.MapGet("users/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetUserDetailsQuery(id), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound("User not found");
});
group.MapGet("clean/dry-run", async (IFileStorageService fileStorage, AuthDbContext identityDb, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct);
return Results.Ok(result.Value);
});
group.MapPost("clean/run", async (IFileStorageService fileStorage, AuthDbContext identityDb, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanRunCommand(fileStorage, identityDb), ct);
return Results.Ok(result.Value);
});
}
}