Зависимости на контракты

This commit is contained in:
Халимов Рустам
2026-03-30 17:48:39 +03:00
parent 09e5cbaa76
commit cb93ff7240
41 changed files with 620 additions and 344 deletions

View File

@@ -0,0 +1,22 @@
namespace Knot.Contracts.Admin.Abstractions;
/// <summary>
/// Базовый класс для агрегатов - сущностей с бизнес-логикой и доменными событиями.
/// </summary>
public abstract class AggregateRoot<TId> : Entity<TId> where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected AggregateRoot(TId id) : base(id) { }
protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}

View File

@@ -0,0 +1,43 @@
namespace Knot.Contracts.Admin.Abstractions;
/// <summary>
/// Базовый класс для сущностей с уникальным идентификатором.
/// </summary>
public abstract class Entity<TId> where TId : notnull
{
public TId Id { get; protected set; }
protected Entity(TId id)
{
Id = id;
}
public override bool Equals(object? obj)
{
if (obj is not Entity<TId> other)
return false;
if (ReferenceEquals(this, other))
return true;
if (GetType() != other.GetType())
return false;
return Id.Equals(other.Id);
}
public override int GetHashCode() => Id.GetHashCode();
public static bool operator ==(Entity<TId>? a, Entity<TId>? b)
{
if (a is null && b is null)
return true;
if (a is null || b is null)
return false;
return a.Equals(b);
}
public static bool operator !=(Entity<TId>? a, Entity<TId>? b) => !(a == b);
}

View File

@@ -0,0 +1,15 @@
using MediatR;
namespace Knot.Contracts.Admin.Abstractions;
public interface ICommand : IRequest<Result> { }
public interface ICommand<TResponse> : IRequest<Result<TResponse>> { }
public interface ICommandHandler<in TCommand> : IRequestHandler<TCommand, Result>
where TCommand : ICommand
{ }
public interface ICommandHandler<in TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
where TCommand : ICommand<TResponse>
{ }

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace Knot.Contracts.Admin.Abstractions;
/// <summary>
/// Интерфейс для доменных событий.
/// </summary>
public interface IDomainEvent : INotification { }

View File

@@ -0,0 +1,13 @@
using MediatR;
namespace Knot.Contracts.Admin.Abstractions;
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
{
}
public interface IQueryHandler<TQuery, TResponse>
: IRequestHandler<TQuery, Result<TResponse>>
where TQuery : IQuery<TResponse>
{
}

View File

@@ -0,0 +1,3 @@
namespace Knot.Contracts.Admin.Abstractions;
public record MessageResponse(string Message);

View File

@@ -0,0 +1,63 @@
namespace Knot.Contracts.Admin.Abstractions;
/// <summary>
/// Представляет ошибку в доменной логике.
/// </summary>
public sealed record Error(string Code, string Description)
{
public static readonly Error None = new(string.Empty, string.Empty);
public static Error NotFound(string code, string description) => new(code, description);
}
/// <summary>
/// Общая обертка для результата операции. Позволяет избегать использования исключений для управления потоком.
/// </summary>
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None)
{
throw new InvalidOperationException();
}
if (!isSuccess && error == Error.None)
{
throw new InvalidOperationException();
}
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
public static Result<TValue> Success<TValue>(TValue value) => Result<TValue>.Success(value);
public static Result<TValue> Failure<TValue>(Error error) => Result<TValue>.Failure(error);
}
/// <summary>
/// Результ операции, содержащий значение.
/// </summary>
public class Result<TValue> : Result
{
private readonly TValue? _value;
protected internal Result(TValue? value, bool isSuccess, Error error)
: base(isSuccess, error)
{
_value = value;
}
public TValue Value => IsSuccess
? _value!
: throw new InvalidOperationException("Нельзя получить значение ошибочного результата.");
public static Result<TValue> Success(TValue value) => new(value, true, Error.None);
public new static Result<TValue> Failure(Error error) => new(default, false, error);
}

View File

@@ -0,0 +1,3 @@
namespace Knot.Contracts.Admin.Abstractions;
public record SuccessResponse(bool Success);

View File

@@ -6,10 +6,6 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Auth.Domain;
namespace Knot.Contracts.Auth.Infrastructure.Persistence;
public interface IAuthDbContext
{
IQueryable<UserContract> Users { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,20 +1,18 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Domain;
namespace Knot.Contracts.Conversations.Infrastructure.Persistence;
public interface IChatsDbContext : IChatsUnitOfWork
public interface IChatsDbContext
{
IQueryable<Chat> Chats { get; }
}
public class Chat : AggregateRoot<Guid>
/// <summary>
/// Упрощенная проекция чата для запросов (не доменная сущность).
/// </summary>
public class Chat
{
public Chat() : base(Guid.NewGuid()) { }
public Guid Id { get; set; }
public string? Avatar { get; set; }
}
}

View File

@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Klipy.Application.Abstractions;
public interface IKlipyClient
{
Task<bool> TestConnectionAsync(string apiKey, string appName, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Messaging.Application.Abstractions;
public interface IMessageQueryService
{
Task<List<MessageInfo>> GetAllMessagesAsync(CancellationToken cancellationToken);
Task<List<MessageInfo>> GetOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken cancellationToken);
Task DeleteMessagesAsync(List<Guid> messageIds, CancellationToken cancellationToken);
}
public record MessageInfo(Guid Id, Guid ChatId, bool IsDeleted, string? MediaUrl, List<MediaInfo>? Media);
public record MediaInfo(string Url);

View File

@@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Messaging.Application.Abstractions;
public interface IUserStatsService
{
Task<Dictionary<Guid, UserStats>> GetStatsForUsersAsync(List<Guid> userIds, CancellationToken cancellationToken = default);
}
public record UserStats(int MessageCount, long StorageSize);

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Settings.Abstractions;
public interface IStatisticsService
{
Task<DashboardStatsDto> GetDashboardStatsAsync(CancellationToken cancellationToken = default);
}
public class DashboardStatsDto
{
public long StorageUsedBytes { get; set; }
public long StorageLimitBytes { get; set; }
public long OnlineUsers { get; set; }
public long OfflineUsers { get; set; }
public long TotalUsers { get; set; }
public List<ActivityStatDto> ActivityTimeline { get; set; } = new();
public List<TopUserDto> TopUsersByMessages { get; set; } = new();
public List<TopUserDto> TopUsersByStorage { get; set; } = new();
}
public class ActivityStatDto
{
public DateTime Date { get; set; }
public long Messages { get; set; }
public long FilesSize { get; set; }
}
public class TopUserDto
{
public Guid UserId { get; set; }
public string Username { get; set; } = string.Empty;
public long Value { get; set; } // messages count or bytes
}

View File

@@ -0,0 +1,25 @@
using System.IO;
using System.Threading.Tasks;
namespace Knot.Contracts.Storage.Abstractions;
public interface IFileStorageService
{
// Загружает поток файла и возвращает его уникальный идентификатор (SHA256 хеш или GUID).
Task<string> UploadFileAsync(Stream stream, string fileName, string contentType);
// Скачивает файл и возвращает его расшифрованный поток и тип содержимого.
Task<(Stream Stream, string ContentType, string FileName)> DownloadFileAsync(string fileId);
// Удаляет файл из хранилища.
Task DeleteFileAsync(string fileId);
// Получает список всех файлов в хранилище с их размерами.
Task<IEnumerable<(string FileId, long Size)>> ListFilesAsync();
// Получает размер конкретного файла
Task<long> GetFileSizeAsync(string fileId, CancellationToken ct = default);
// Получает размеры списка файлов
Task<Dictionary<string, long>> GetFileSizesAsync(IEnumerable<string> fileIds, CancellationToken ct = default);
}

View File

@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Contracts.Stories.Infrastructure.Persistence;
public interface IStoryCollection
{
Task<List<StoryContract>> GetAllAsync(CancellationToken cancellationToken);
}
public class StoryContract
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string? MediaUrl { get; set; }
public string? MediaType { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
}

View File

@@ -1,16 +1,16 @@
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Shared.Kernel;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands;
public record BanUserCommand(Guid UserId) : ICommand<Result>;
public record BanUserCommand(Guid UserId) : ICommand<SuccessResponse>;
internal sealed class BanUserCommandHandler : ICommandHandler<BanUserCommand, Result>
internal sealed class BanUserCommandHandler : ICommandHandler<BanUserCommand, SuccessResponse>
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork;
@@ -21,16 +21,16 @@ internal sealed class BanUserCommandHandler : ICommandHandler<BanUserCommand, Re
_unitOfWork = unitOfWork;
}
public async Task<Result<Result>> Handle(BanUserCommand request, CancellationToken cancellationToken)
public async Task<Result<SuccessResponse>> Handle(BanUserCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null) return Result.Failure<Result>(Error.NotFound("User.NotFound", "User not found"));
if (user == null) return Result.Failure<SuccessResponse>(Error.NotFound("User.NotFound", "User not found"));
user.IsBanned = true;
user.BannedUntil = null; // Permanent ban
await _userRepository.UpdateAsync(user, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(Result.Success());
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -1,16 +1,16 @@
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Shared.Kernel;
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands;
public record UnbanUserCommand(Guid UserId) : ICommand<Result>;
public record UnbanUserCommand(Guid UserId) : ICommand<SuccessResponse>;
internal sealed class UnbanUserCommandHandler : ICommandHandler<UnbanUserCommand, Result>
internal sealed class UnbanUserCommandHandler : ICommandHandler<UnbanUserCommand, SuccessResponse>
{
private readonly IUserRepository _userRepository;
private readonly IAuthUnitOfWork _unitOfWork;
@@ -21,16 +21,16 @@ internal sealed class UnbanUserCommandHandler : ICommandHandler<UnbanUserCommand
_unitOfWork = unitOfWork;
}
public async Task<Result<Result>> Handle(UnbanUserCommand request, CancellationToken cancellationToken)
public async Task<Result<SuccessResponse>> Handle(UnbanUserCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null) return Result.Failure<Result>(Error.NotFound("User.NotFound", "User not found"));
if (user == null) return Result.Failure<SuccessResponse>(Error.NotFound("User.NotFound", "User not found"));
user.IsBanned = false;
user.BannedUntil = null;
await _userRepository.UpdateAsync(user, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(Result.Success());
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -3,17 +3,16 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Storage.Abstractions;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Stories.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MediatR;
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Commands;
@@ -21,17 +20,22 @@ public record CleanRunCommand() : ICommand<MessageResponse>;
internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand, MessageResponse>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
private readonly IMongoCollection<Story> _stories;
private readonly Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext _chatsDbContext;
private readonly Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService _messageQueryService;
private readonly Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection _storyCollection;
private readonly IFileStorageService _fileStorage;
private readonly AuthDbContext _identityDb;
private readonly Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext _identityDb;
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb, IFileStorageService fileStorage, AuthDbContext identityDb)
public CleanRunCommandHandler(
Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext chatsDbContext,
Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService messageQueryService,
Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection storyCollection,
IFileStorageService fileStorage,
Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext identityDb)
{
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages");
_stories = mongoDb.GetCollection<Story>("stories");
_messageQueryService = messageQueryService;
_storyCollection = storyCollection;
_fileStorage = fileStorage;
_identityDb = identityDb;
}
@@ -40,30 +44,27 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
{
try
{
var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken);
var allChats = await _chatsDbContext.Chats.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) || m.IsDeleted)
.ToList();
var allMessages = await _messageQueryService.GetAllMessagesAsync(cancellationToken);
var orphanMessages = await _messageQueryService.GetOrphanedMessagesAsync(activeChatIds, cancellationToken);
var keptMessages = allMessages
.Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted)
.Where(m => !orphanMessages.Any(om => om.Id == m.Id))
.ToList();
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken);
var allUsers = await _identityDb.Users.ToListAsync(cancellationToken);
var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
var allStories = await _storyCollection.GetAllAsync(cancellationToken);
var validUrls = new HashSet<string>();
var activeMessageUrls = keptMessages.OfType<MediaMessage>()
var activeMessageUrls = keptMessages
.Where(m => m.Media != null)
.SelectMany(m => m.Media)
.SelectMany(m => m.Media!)
.Select(me => me.Url)
.Where(u => !string.IsNullOrEmpty(u));
@@ -100,8 +101,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
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);
await _messageQueryService.DeleteMessagesAsync(orphanIds, cancellationToken);
}
return Result.Success(new MessageResponse("Cleanup completed successfully"));

View File

@@ -6,7 +6,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Contracts.Admin.Abstractions;
namespace Knot.Modules.Admin.Application.Admin.Commands;
@@ -28,10 +28,10 @@ internal sealed class ResetUserPasswordCommandHandler : ICommandHandler<ResetUse
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<SuccessResponse>(AuthErrors.UserNotFound);
return Result.Failure<SuccessResponse>(Error.NotFound("Auth.UserNotFound", "User not found"));
}
if (string.IsNullOrWhiteSpace(request.NewPassword))
if (string.IsNullOrWhiteSpace(request.NewPassword))
return Result.Failure<SuccessResponse>(new Error("Admin.InvalidPassword", "Invalid password"));
var hash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
@@ -42,3 +42,4 @@ internal sealed class ResetUserPasswordCommandHandler : ICommandHandler<ResetUse
return Result.Success(new SuccessResponse(true));
}
}

View File

@@ -1,10 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Klipy.Application.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Klipy.Application.Abstractions;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
@@ -13,9 +13,9 @@ public record TestKlipyConnectionCommand(string ApiKey, string AppName) : IComma
internal sealed class TestKlipyConnectionCommandHandler : ICommandHandler<TestKlipyConnectionCommand, bool>
{
private readonly ISettingsService _settingsService;
private readonly IKlipyClient _klipyClient;
private readonly Knot.Contracts.Klipy.Application.Abstractions.IKlipyClient _klipyClient;
public TestKlipyConnectionCommandHandler(ISettingsService settingsService, IKlipyClient klipyClient)
public TestKlipyConnectionCommandHandler(ISettingsService settingsService, Knot.Contracts.Klipy.Application.Abstractions.IKlipyClient klipyClient)
{
_settingsService = settingsService;
_klipyClient = klipyClient;

View File

@@ -1,12 +1,12 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
using System.Security.Cryptography;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Commands;
@@ -26,19 +26,19 @@ internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSetti
public async Task<Result<SystemSettingsDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
{
// Если федерация включена, но ключи еще не сгенерированы — создаем их
if (request.Settings.Federation.Enabled &&
(string.IsNullOrEmpty(request.Settings.Federation.PrivateKey) ||
if (request.Settings.Federation.Enabled &&
(string.IsNullOrEmpty(request.Settings.Federation.PrivateKey) ||
string.IsNullOrEmpty(request.Settings.Federation.PublicKey)))
{
using var rsa = RSA.Create(2048);
// Экспорт в формате PKCS#8 (Private) и PKCS#1 (Public) в Base64 PEM-строки
request.Settings.Federation.PrivateKey = Convert.ToBase64String(rsa.ExportPkcs8PrivateKey());
request.Settings.Federation.PublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey());
}
await _settingsService.UpdateSettingsAsync(request.Settings, cancellationToken);
// Уведомляем другие модули (в т.ч. Федерацию) через Доменное Событие
await _mediator.Publish(new Knot.Modules.Admin.Domain.Events.SystemSettingsUpdatedDomainEvent(request.Settings), cancellationToken);

View File

@@ -0,0 +1,9 @@
namespace Knot.Modules.Admin.Application.Admin.DTOs;
public record CleanDryRunResult(
int OrphanedMessagesCount,
int OrphanedMediaCount,
long OrphanedMediaBytes,
int ExpiredStoriesCount,
long ExpiredStoriesSize
);

View File

@@ -1,136 +1,77 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Application.Users;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Stories.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MediatR;
using Microsoft.EntityFrameworkCore;
using MongoDB.Bson;
using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Queries;
public record CleanDryRunQuery() : IQuery<CleanupDryRunResultDto>;
public record CleanDryRunQuery() : IQuery<CleanDryRunResult>;
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanupDryRunResultDto>
internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery, CleanDryRunResult>
{
private readonly ChatsDbContext _chatsDbContext;
private readonly IMongoCollection<Message> _messages;
private readonly IMongoCollection<Story> _stories;
private readonly IFileStorageService _fileStorage;
private readonly AuthDbContext _identityDb;
private readonly Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService _messageService;
private readonly IAuthDbContext _authDbContext;
private readonly IChatsDbContext _chatsDbContext;
private readonly IStoryCollection _storyCollection;
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb, IFileStorageService fileStorage, AuthDbContext identityDb)
public CleanDryRunQueryHandler(
Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService messageService,
IAuthDbContext authDbContext,
IChatsDbContext chatsDbContext,
IStoryCollection storyCollection)
{
_messageService = messageService;
_authDbContext = authDbContext;
_chatsDbContext = chatsDbContext;
_messages = mongoDb.GetCollection<Message>("messages");
_stories = mongoDb.GetCollection<Story>("stories");
_fileStorage = fileStorage;
_identityDb = identityDb;
_storyCollection = storyCollection;
}
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken ct)
public async Task<Result<CleanDryRunResult>> Handle(CleanDryRunQuery request, CancellationToken ct)
{
try
{
// 1. Получаем ID активных чатов (SQL)
var activeChats = await _chatsDbContext.Chats
.AsNoTracking()
.Select(c => new { c.Id, c.Avatar })
.ToListAsync(ct);
var activeChatIds = _chatsDbContext.Chats.Select(c => c.Id).ToHashSet();
var activeChatIds = activeChats.Select(c => c.Id).ToHashSet();
var orphanedMessages = await _messageService.GetOrphanedMessagesAsync(activeChatIds, ct);
var orphanedMediaCount = orphanedMessages.Count(m => m.MediaUrl != null);
var orphanedMessageCount = orphanedMessages.Count;
// 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);
// 3. Собираем ID всех используемых файлов
var validFileIds = new HashSet<string>();
// Аватары чатов и пользователей
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).ToListAsync(ct);
foreach (var avatar in userAvatars) AddFileIdIfValid(avatar, validFileIds);
// Медиа из актуальных сторис
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 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))
var validIds = new HashSet<string>();
foreach (var msg in orphanedMessages.Where(m => m.MediaUrl != null))
{
while (await cursor.MoveNextAsync(ct))
var parts = msg.MediaUrl.Split('/');
var fileId = parts.LastOrDefault();
if (!string.IsNullOrEmpty(fileId))
{
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);
}
}
}
validIds.Add(fileId);
}
}
// 4. Анализ физического хранилища
var allStoredFiles = await _fileStorage.ListFilesAsync();
long orphanedBytes = allStoredFiles
.Where(file => !validFileIds.Contains(file.FileId))
.Sum(file => file.Size);
var stories = await _storyCollection.GetAllAsync(ct);
var expiredStoriesCount = stories.Count(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow);
var expiredStoriesSize = stories.Where(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow).Sum(s => s.MediaUrl?.Length ?? 0);
return Result.Success(new CleanupDryRunResultDto
{
OrphanedMessagesCount = (int)orphanedMessagesCount,
OrphanedMediaBytes = orphanedBytes
});
return Result.Success(new CleanDryRunResult(
orphanedMessageCount,
orphanedMediaCount,
0,
expiredStoriesCount,
expiredStoriesSize
));
}
catch (Exception ex)
{
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);
}
return Result.Failure<CleanDryRunResult>(new Error("Admin.CleanDryRun.Error", $"Error during dry run: {ex.Message}"));
}
}
}

View File

@@ -1,9 +1,9 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Settings.Abstractions;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Services;
namespace Knot.Modules.Admin.Application.Admin.Queries;

View File

@@ -1,11 +1,11 @@
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
namespace Knot.Modules.Admin.Application.Admin.Queries;

View File

@@ -1,91 +1,64 @@
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Modules.Admin.Application.Admin.DTOs;
using Knot.Modules.Messaging.Domain;
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain;
using MongoDB.Driver;
namespace Knot.Modules.Admin.Application.Admin.Queries;
public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDetailsDto>;
public record GetUserDetailsQuery(Guid UserId) : IQuery<AdminUserDto>;
internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDetailsDto>
internal sealed class GetUserDetailsQueryHandler : IQueryHandler<GetUserDetailsQuery, AdminUserDto>
{
private readonly IUserRepository _userRepository;
private readonly IMongoCollection<Message> _messages;
private readonly Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService _statsService;
private readonly Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService _statusService;
public GetUserDetailsQueryHandler(IUserRepository userRepository, IMongoDatabase mongoDatabase)
public GetUserDetailsQueryHandler(
IUserRepository userRepository,
Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService statsService,
Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService statusService)
{
_userRepository = userRepository;
_messages = mongoDatabase.GetCollection<Message>("Messages");
_statsService = statsService;
_statusService = statusService;
}
public async Task<Result<AdminUserDetailsDto>> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken)
public async Task<Result<AdminUserDto>> Handle(GetUserDetailsQuery request, CancellationToken ct)
{
var targetUser = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (targetUser == null)
try
{
return Result.Failure<AdminUserDetailsDto>(AuthErrors.UserNotFound);
}
var user = await _userRepository.GetByIdAsync(request.UserId, ct);
if (user == null)
return Result.Failure<AdminUserDto>(new Error("Admin.GetUserDetails.NotFound", "User not found"));
List<Message> userMessages;
try
var statsMap = await _statsService.GetStatsForUsersAsync(new List<Guid> { request.UserId }, ct);
var stats = statsMap.GetValueOrDefault(request.UserId, new Knot.Contracts.Messaging.Application.Abstractions.UserStats(0, 0L));
var isOnline = _statusService.IsUserOnline(request.UserId.ToString());
return Result.Success(new AdminUserDto(
user.Id,
user.Username ?? "unknown",
user.DisplayName ?? "User",
user.Email,
user.Avatar,
user.CreatedAt,
isOnline,
isOnline ? DateTime.UtcNow : (user.CreatedAt),
user.IsBanned,
stats.MessageCount,
stats.StorageSize
));
}
catch (Exception ex)
{
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, request.UserId);
userMessages = await _messages.Find(filter).ToListAsync(cancellationToken);
return Result.Failure<AdminUserDto>(new Error("Admin.GetUserDetails.Error", $"Error getting user details: {ex.Message}"));
}
catch (Exception)
{
userMessages = new List<Message>();
}
var messagesCount = userMessages.Count;
var mediaMessages = userMessages.OfType<MediaMessage>().ToList();
var allUserMedia = mediaMessages
.Where(m => m.Media != null)
.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(mediaMessages.Where(m => m.Caption != null).Select(m => m.Caption))
.Where(c => c != null)
.ToList();
var linksCount = userContents.Count(c => c != null && c.Contains("http", StringComparison.OrdinalIgnoreCase));
var isOnline = Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString());
var result = new AdminUserDetailsDto(
targetUser.Id,
targetUser.Username ?? "unknown",
targetUser.DisplayName ?? "User",
targetUser.Bio,
targetUser.Avatar,
targetUser.CreatedAt,
isOnline,
isOnline ? DateTime.UtcNow : (targetUser.CreatedAt),
targetUser.IsBanned,
new AdminUserStatsDto(
messagesCount,
mediaCount,
filesCount,
linksCount,
storageUsed
)
);
return Result.Success(result);
}
}

View File

@@ -1,14 +1,14 @@
using Knot.Contracts.Auth.Domain;
using Knot.Modules.Admin.Application.Admin.DTOs;
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Admin.Application.Admin.DTOs;
using MediatR;
using Knot.Shared.Kernel;
using Knot.Modules.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
namespace Knot.Modules.Admin.Application.Admin.Queries;
@@ -17,13 +17,13 @@ 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;
private readonly Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService _statsService;
private readonly Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService _statusService;
public SearchUsersQueryHandler(
IUserRepository userRepository,
IUserStatsService statsService,
IUserStatusService statusService)
IUserRepository userRepository,
Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService statsService,
Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService statusService)
{
_userRepository = userRepository;
_statsService = statsService;
@@ -34,20 +34,19 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery,
{
try
{
// 1. Ïîèñê ïîëüçîâàòåëåé â ðåëÿöèîííîé ÁÄ
var users = await _userRepository.SearchUsersAsync(request.Query ?? "", ct);
if (!users.Any()) return Result.Success(new List<AdminUserDto>());
var userIds = users.Select(u => u.Id).ToList();
// 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 result = users.Select(u =>
{
var stats = statsMap.GetValueOrDefault(u.Id, new Knot.Contracts.Messaging.Application.Abstractions.UserStats(0, 0L));
var isOnline = _statusService.IsUserOnline(u.Id.ToString());
return new AdminUserDto(
u.Id,
u.Username ?? "unknown",
@@ -67,7 +66,7 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery,
}
catch (Exception ex)
{
return Result.Failure<List<AdminUserDto>>(new Error("Admin.SearchUsers.Error", $"Îøèáêà ïðè ïîèñêå ïîëüçîâàòåëåé: {ex.Message}"));
return Result.Failure<List<AdminUserDto>>(new Error("Admin.SearchUsers.Error", $"Error searching users: {ex.Message}"));
}
}
}

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using Knot.Shared.Kernel;
using Knot.Contracts.Admin.Abstractions;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
@@ -10,3 +10,4 @@ namespace Knot.Modules.Admin.Domain.Events;
/// </summary>
public sealed record SystemSettingsUpdatedDomainEvent(SystemSettingsDto Settings) : IDomainEvent;

View File

@@ -4,9 +4,9 @@
<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="..\..\Contracts\Admin\Knot.Contracts.Admin.csproj" />
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
@@ -14,14 +14,12 @@
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
<ProjectReference Include="..\..\Contracts\Stories\Knot.Contracts.Stories.csproj" />
<ProjectReference Include="..\..\Contracts\Klipy\Knot.Contracts.Klipy.csproj" />
<!-- Admin needs direct module access for cleanup operations - PrivateAssets prevents module exposure -->
<ProjectReference Include="..\Auth\Knot.Modules.Auth.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Conversations\Knot.Modules.Conversations.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Stories\Knot.Modules.Stories.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Klipy\Knot.Modules.Klipy.csproj" PrivateAssets="all" />
<ProjectReference Include="..\Messaging\Knot.Modules.Messaging.csproj" PrivateAssets="all" />
<ProjectReference Include="..\..\Contracts\Storage\Knot.Contracts.Storage.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Carter" Version="10.0.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
</ItemGroup>
</Project>

View File

@@ -1,22 +1,19 @@
using Carter;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Settings.Application.DTOs;
using Knot.Modules.Auth.Application.Users.Register;
using MediatR;
using Knot.Modules.Admin.Application.Admin.Queries;
using Knot.Modules.Admin.Application.Admin.Commands;
using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
using Knot.Modules.Admin.Application.Admin.Queries;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy;
using Knot.Modules.Conversations.Application.Users.Commands.DeleteUser;
using Microsoft.AspNetCore.Routing;
namespace Knot.Host.Presentation.Endpoints;
public record KlipyTestDto(string ApiKey, string AppName);
public record ResetPasswordRequest(string NewPassword);
public sealed class AdminEndpoints : ICarterModule
{
@@ -47,20 +44,11 @@ public sealed class AdminEndpoints : ICarterModule
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.UserId), ct);
return Results.Ok(userDetails.Value);
});
group.MapPost("users/{userId:guid}/reset-password", async (Guid userId, [FromBody] ResetPasswordDto dto, ISender sender, CancellationToken ct) =>
group.MapPost("users/{userId:guid}/reset-password", async (Guid userId, [FromBody] ResetPasswordRequest dto, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct);
if (result.IsFailure)
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 });
@@ -80,12 +68,6 @@ public sealed class AdminEndpoints : ICarterModule
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapDelete("users/{userId:guid}", async (Guid userId, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new DeleteUserCommand(userId), ct);
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) =>
{
var result = await sender.Send(new SearchUsersQuery(query), ct);
@@ -117,7 +99,8 @@ public sealed class AdminEndpoints : ICarterModule
{
// Получаем все системные часовые пояса и формируем удобный для фронтенда формат
var zones = TimeZoneInfo.GetSystemTimeZones()
.Select(tz => new {
.Select(tz => new
{
id = tz.Id,
displayName = tz.DisplayName,
standardName = tz.StandardName,

View File

@@ -1,5 +1,6 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Infrastructure.Authentication;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Shared.Kernel;
@@ -11,27 +12,22 @@ namespace Knot.Modules.Auth;
public static class DependencyInjection
{
/// <summary>
/// Регистрация сервисов модуля Identity.
/// </summary>
public static IServiceCollection AddAuthModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Настройка базы данных
string connectionString = configuration.GetConnectionString("DefaultConnection")!;
services.AddDbContext<AuthDbContext>(options =>
options.UseNpgsql(connectionString));
// Регистрация Unit of Work и Репозиториев
services.AddScoped<IAuthUnitOfWork>(sp => sp.GetRequiredService<AuthDbContext>());
services.AddScoped<IAuthDbContext>(sp => sp.GetRequiredService<AuthDbContext>());
services.AddScoped<Knot.Contracts.Auth.Application.Abstractions.IAuthDbContext>(sp => sp.GetRequiredService<AuthDbContext>());
services.AddScoped<Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext>(sp => sp.GetRequiredService<AuthDbContext>());
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IJwtTokenProvider, JwtTokenProvider>();
services.AddScoped<IUserDisplayNameProvider, Knot.Modules.Auth.Infrastructure.Services.UserDisplayNameProvider>();
// Регистрация MediatR для этого модуля
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));

View File

@@ -1,5 +1,6 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Infrastructure.Persistence;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Security;
using MediatR;
@@ -9,7 +10,7 @@ using DomainUser = Knot.Modules.Auth.Domain.User;
namespace Knot.Modules.Auth.Infrastructure.Persistence;
public sealed class AuthDbContext : DbContext, IAuthUnitOfWork, IAuthDbContext
public sealed class AuthDbContext : DbContext, IAuthUnitOfWork, Knot.Contracts.Auth.Application.Abstractions.IAuthDbContext, Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext
{
private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService;
@@ -31,6 +32,21 @@ public sealed class AuthDbContext : DbContext, IAuthUnitOfWork, IAuthDbContext
Set<DomainUser>().Add(domainUser);
}
IQueryable<UserContract> Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext.Users =>
Set<DomainUser>().Select(u => new UserContract
{
Id = u.Id,
Username = u.Username,
DisplayName = u.DisplayName,
Email = u.Email,
Avatar = u.Avatar,
PhoneNumber = u.PhoneNumber,
CreatedAt = u.CreatedAt,
IsBanned = u.IsBanned,
Bio = u.Bio,
PasswordHash = u.PasswordHash
});
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);

View File

@@ -1,4 +1,5 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.Abstractions;
@@ -29,6 +30,7 @@ public static class DependencyInjection
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
services.AddScoped<ConversationsAbstractions.IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext>(sp => sp.GetRequiredService<ChatsDbContext>());
services.AddScoped<IChatRepository, ChatRepository>();
services.AddScoped<IFolderRepository, FolderRepository>();
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
@@ -11,13 +12,11 @@ using Knot.Shared.Kernel.Security;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using DomainChat = Knot.Modules.Conversations.Domain.Chat;
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
/// <summary>
/// Контекст базы данных для модуля чатов.
/// </summary>
public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork
public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork, Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext
{
private readonly IMediator _mediator;
private readonly IEncryptionService _encryptionService;
@@ -29,10 +28,13 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
_encryptionService = encryptionService;
}
public DbSet<Chat> Chats => Set<Chat>();
public DbSet<DomainChat> Chats => Set<DomainChat>();
public DbSet<Folder> Folders => Set<Folder>();
public DbSet<UserChatSettings> UserChatSettings => Set<UserChatSettings>();
IQueryable<Knot.Contracts.Conversations.Infrastructure.Persistence.Chat> Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext.Chats =>
Set<DomainChat>().Select(c => new Knot.Contracts.Conversations.Infrastructure.Persistence.Chat { Avatar = c.Avatar });
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
@@ -43,20 +45,20 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
{
modelBuilder.HasDefaultSchema("chats");
modelBuilder.Entity<Chat>(builder =>
{
builder.ToTable("Chats");
builder.HasKey(c => c.Id);
builder.Property(c => c.Type).HasConversion<string>();
modelBuilder.Entity<DomainChat>(builder =>
{
builder.ToTable("Chats");
builder.HasKey(c => c.Id);
builder.Property(c => c.Type).HasConversion<string>();
builder.OwnsMany(c => c.Members, mb =>
{
mb.ToTable("ChatMembers");
mb.HasKey(m => m.Id);
mb.WithOwner().HasForeignKey(m => m.ChatId);
mb.HasIndex(m => new { m.ChatId, m.UserId }).IsUnique();
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
});
builder.OwnsMany(c => c.Members, mb =>
{
mb.ToTable("ChatMembers");
mb.HasKey(m => m.Id);
mb.WithOwner().HasForeignKey(m => m.ChatId);
mb.HasIndex(m => new { m.ChatId, m.UserId }).IsUnique();
}).Navigation(c => c.Members).UsePropertyAccessMode(PropertyAccessMode.Field);
});
modelBuilder.Entity<Folder>(builder =>
{

View File

@@ -7,7 +7,7 @@ using Knot.Modules.Klipy.Application.Abstractions;
namespace Knot.Modules.Klipy.Infrastructure.External;
public sealed class KlipyClient : IKlipyClient
public sealed class KlipyClient : Knot.Modules.Klipy.Application.Abstractions.IKlipyClient
{
private readonly HttpClient _httpClient;
@@ -20,10 +20,9 @@ public sealed class KlipyClient : IKlipyClient
{
try
{
// According to docs: https://api.klipy.com/api/v1/{app_key}/gifs/trending
var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.klipy.com/api/v1/{appName}/gifs/trending?page=1&per_page=1&customer_id=knot_admin_test");
request.Headers.Add("X-KLIPY-API-KEY", apiKey);
using var response = await _httpClient.SendAsync(request, ct);
return response.IsSuccessStatusCode;
}
@@ -35,7 +34,6 @@ public sealed class KlipyClient : IKlipyClient
public async Task<List<string>> SearchVideosAsync(string apiKey, string appName, string query, int limit, CancellationToken ct = default)
{
// To be implemented as needed
return new List<string>();
}
}

View File

@@ -7,6 +7,8 @@ using Knot.Shared.Kernel;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using ContractUserStatsService = Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService;
using ModuleUserStatsService = Knot.Modules.Messaging.Application.Abstractions.IUserStatsService;
namespace Knot.Modules.Messaging;
@@ -27,7 +29,8 @@ public static class DependencyInjection
services.AddScoped<IMessageRepository, MessageRepository>();
services.AddScoped<IMessageReactionRepository, MessageReactionRepository>();
services.AddScoped<IUserStatsService, UserStatsService>();
services.AddScoped<ModuleUserStatsService, UserStatsService>();
services.AddScoped<ContractUserStatsService>(sp => (ContractUserStatsService)sp.GetRequiredService<ModuleUserStatsService>());
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));

View File

@@ -4,15 +4,17 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Messaging.Abstractions;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Messaging.Application.Abstractions;
using MongoDB.Bson;
using MongoDB.Driver;
using ContractUserStatsService = Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService;
using ModuleUserStatsService = Knot.Modules.Messaging.Application.Abstractions.IUserStatsService;
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
public sealed class UserStatsService : ModuleUserStatsService
public sealed class UserStatsService : ModuleUserStatsService, ContractUserStatsService
{
private readonly IMongoCollection<Message> _messages;
@@ -23,11 +25,10 @@ public sealed class UserStatsService : ModuleUserStatsService
public async Task<Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
{
var userIdList = userIds.ToList();
if (!userIdList.Any()) return new Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>();
if (!userIds.Any()) return new Dictionary<Guid, Knot.Modules.Messaging.Application.Abstractions.UserStats>();
var stats = await _messages.Aggregate()
.Match(Builders<Message>.Filter.In(m => m.SenderId, userIdList))
.Match(Builders<Message>.Filter.In(m => m.SenderId, userIds))
.Group(new BsonDocument {
{ "_id", "$SenderId" },
{ "Count", new BsonDocument("$sum", 1) },
@@ -44,6 +45,28 @@ public sealed class UserStatsService : ModuleUserStatsService
);
}
async Task<Dictionary<Guid, Knot.Contracts.Messaging.Application.Abstractions.UserStats>> Knot.Contracts.Messaging.Application.Abstractions.IUserStatsService.GetStatsForUsersAsync(List<Guid> userIds, CancellationToken cancellationToken = default)
{
if (!userIds.Any()) return new Dictionary<Guid, Knot.Contracts.Messaging.Application.Abstractions.UserStats>();
var stats = await _messages.Aggregate()
.Match(Builders<Message>.Filter.In(m => m.SenderId, userIds))
.Group(new BsonDocument {
{ "_id", "$SenderId" },
{ "Count", new BsonDocument("$sum", 1) },
{ "MediaSize", new BsonDocument("$sum", new BsonDocument("$sum", "$Media.Size")) }
})
.ToListAsync(cancellationToken);
return stats.ToDictionary(
doc => doc["_id"].AsGuid,
doc => new Knot.Contracts.Messaging.Application.Abstractions.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()

View File

@@ -1,10 +1,11 @@
using System.Net.Http;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
using MongoDB.Driver;
using System.Net.Http;
namespace Knot.Modules.Stories;
@@ -13,12 +14,12 @@ public static class DependencyInjection
public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<IStoryRepository, StoryRepository>();
services.AddScoped<Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection, StoryCollection>();
var connectionString = configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<Infrastructure.Database.StoriesDbContext>(options =>
options.UseNpgsql(connectionString));
// MongoDB configuration happens in StoriesMongoMapConfigurator (called in Program.cs when EncryptionService is ready, or we can just call it here)
StoriesMongoMapConfigurator.Configure();
services.AddMediatR(config =>

View File

@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Stories.Infrastructure.Persistence;
using MongoDB.Driver;
using DomainStory = Knot.Modules.Stories.Domain.Story;
namespace Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
public class StoryCollection : Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection
{
private readonly IMongoCollection<DomainStory> _stories;
public StoryCollection(IMongoDatabase database)
{
_stories = database.GetCollection<DomainStory>("stories");
}
public async Task<List<Knot.Contracts.Stories.Infrastructure.Persistence.StoryContract>> GetAllAsync(CancellationToken cancellationToken)
{
var stories = await _stories.Find(_ => true).ToListAsync(cancellationToken);
return stories.Select(s => new Knot.Contracts.Stories.Infrastructure.Persistence.StoryContract
{
Id = s.Id,
UserId = s.UserId,
MediaUrl = s.MediaUrl,
MediaType = s.Type.ToString(),
CreatedAt = s.CreatedAt,
ExpiresAt = null
}).ToList();
}
}