Структура, доп модули, федерация, документация

This commit is contained in:
Халимов Рустам
2026-03-27 00:55:01 +03:00
parent 7cb6ac61dd
commit 7ef73b414c
64 changed files with 3080 additions and 133 deletions

View File

@@ -0,0 +1,24 @@
using System;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Domain;
/// <summary>
/// Доменное событие: сообщение отредактировано.
/// </summary>
public sealed record MessageEditedDomainEvent(Guid MessageId, Guid ChatId, string NewContent) : IDomainEvent;
/// <summary>
/// Доменное событие: сообщение удалено.
/// </summary>
public sealed record MessageDeletedDomainEvent(Guid MessageId, Guid ChatId) : IDomainEvent;
/// <summary>
/// Доменное событие: добавлена реакция.
/// </summary>
public sealed record MessageReactionAddedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent;
/// <summary>
/// Доменное событие: удалена реакция.
/// </summary>
public sealed record MessageReactionRemovedDomainEvent(Guid MessageId, Guid ChatId, Guid UserId, string Emoji) : IDomainEvent;

View File

@@ -14,6 +14,10 @@ public interface IMessageRepository
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken);
Task<List<Message>> GetAllMessagesAsync(CancellationToken cancellationToken);
Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken);
Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken);
Task RemoveAsync(Guid id, CancellationToken cancellationToken);
}

View File

@@ -56,10 +56,9 @@ public class MediaMessage : Message
}
}
public void Edit(string newCaption)
public override void Edit(string newCaption)
{
Caption = newCaption;
AddState(MessageState.IsEdited);
base.Edit(newCaption);
}
public override void Delete()

View File

@@ -74,6 +74,14 @@ public abstract class Message : AggregateRoot<Guid>
public virtual void Delete()
{
AddState(MessageState.IsDeleted);
RaiseDomainEvent(new MessageDeletedDomainEvent(Id, ChatId));
}
public virtual void Edit(string newContent)
{
Content = newContent;
AddState(MessageState.IsEdited);
RaiseDomainEvent(new MessageEditedDomainEvent(Id, ChatId, newContent));
}
public void DeleteForUser(Guid userId)

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Knot.Modules.Messaging.Domain;
public class PollMessage : Message
{
public override string Type => "poll";
public override string? Content { get; protected set; } // Вопрос опроса
public bool IsAnonymous { get; protected set; }
public bool AllowMultipleAnswers { get; protected set; }
public bool IsClosed { get; protected set; }
public DateTime? ExpiresAt { get; protected set; }
private List<PollOption> _options = new();
public IReadOnlyCollection<PollOption> Options => _options.AsReadOnly();
private List<PollVote> _votes = new();
public IReadOnlyCollection<PollVote> Votes => _votes.AsReadOnly();
// Инфраструктурный конструктор
private PollMessage() : base() { }
public PollMessage(
Guid id,
Guid chatId,
Guid senderId,
string question,
List<string> options,
bool isAnonymous,
bool allowMultipleAnswers,
DateTime? expiresAt,
Guid? replyToId,
Guid? forwardedFromId,
DateTime createdAt,
bool isImported)
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
{
Content = question;
IsAnonymous = isAnonymous;
AllowMultipleAnswers = allowMultipleAnswers;
ExpiresAt = expiresAt;
IsClosed = false;
foreach (var optionText in options)
{
_options.Add(new PollOption(Guid.NewGuid(), Id, optionText));
}
if (!isImported)
{
// Можно добавить специфичное событие для опроса
// RaiseDomainEvent(new PollCreatedDomainEvent(Id, ChatId, SenderId, question));
}
}
public void Vote(Guid userId, List<Guid> optionIds)
{
if (IsClosed) throw new InvalidOperationException("Poll is closed");
if (ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value)
{
Close();
throw new InvalidOperationException("Poll has expired");
}
if (optionIds == null || !optionIds.Any()) throw new ArgumentException("At least one option must be selected");
if (!AllowMultipleAnswers && optionIds.Count > 1) throw new ArgumentException("Multiple answers are not allowed");
// Проверяем существование опций
foreach (var oid in optionIds)
{
if (!_options.Any(o => o.Id == oid)) throw new ArgumentException($"Option {oid} not found");
}
// Удаляем старые голоса пользователя
_votes.RemoveAll(v => v.UserId == userId);
// Добавляем новые голоса
foreach (var oid in optionIds)
{
_votes.Add(new PollVote(Id, userId, oid, DateTime.UtcNow));
}
// Обновляем статистику в опциях
RecalculateResults();
}
public void Close()
{
IsClosed = true;
}
private void RecalculateResults()
{
int totalVotesCount = _votes.Select(v => v.UserId).Distinct().Count();
foreach (var option in _options)
{
int optionVotes = _votes.Count(v => v.OptionId == option.Id);
double percentage = totalVotesCount > 0 ? (double)optionVotes / totalVotesCount * 100 : 0;
option.UpdateResults(optionVotes, (int)Math.Round(percentage));
}
}
public override void Delete()
{
// При удалении опроса можно очистить голоса или оставить для истории (зависит от политики)
base.Delete();
}
}
public class PollOption
{
public Guid Id { get; private set; }
public Guid PollId { get; private set; }
public string Text { get; private set; }
// Денормализованные данные для быстрой отдачи
public int VotesCount { get; private set; }
public int Percentage { get; private set; }
private PollOption() { } // EF
public PollOption(Guid id, Guid pollId, string text)
{
Id = id;
PollId = pollId;
Text = text;
}
public void UpdateResults(int count, int percentage)
{
VotesCount = count;
Percentage = percentage;
}
}
public class PollVote
{
public Guid PollId { get; private set; }
public Guid UserId { get; private set; }
public Guid OptionId { get; private set; }
public DateTime VotedAt { get; private set; }
private PollVote() { } // EF
public PollVote(Guid pollId, Guid userId, Guid optionId, DateTime votedAt)
{
PollId = pollId;
UserId = userId;
OptionId = optionId;
VotedAt = votedAt;
}
}

View File

@@ -34,10 +34,9 @@ public class TextMessage : Message
}
}
public void Edit(string newContent)
public override void Edit(string newContent)
{
Content = newContent;
AddState(MessageState.IsEdited);
base.Edit(newContent);
}
public override void Delete()

View File

@@ -6,10 +6,14 @@ namespace Knot.Modules.Messaging.Infrastructure.Persistence;
public sealed class MessageReactionRepository : IMessageReactionRepository
{
private readonly IMongoCollection<MessageReaction> _reactions;
private readonly MediatR.IMediator _mediator;
private readonly IMessageRepository _messageRepository;
public MessageReactionRepository(IMongoDatabase mongoDatabase)
public MessageReactionRepository(IMongoDatabase mongoDatabase, MediatR.IMediator mediator, IMessageRepository messageRepository)
{
_reactions = mongoDatabase.GetCollection<MessageReaction>("message_reactions");
_mediator = mediator;
_messageRepository = messageRepository;
// Ensure index for fast querying by message
var indexKeysDefinition = Builders<MessageReaction>.IndexKeys.Ascending(r => r.MessageId);
@@ -27,6 +31,13 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
// Используем ReplaceOptions.IsUpsert = true для идемпотентности (нет гонок)
await _reactions.ReplaceOneAsync(filter, reaction, new ReplaceOptions { IsUpsert = true }, cancellationToken);
// Уведомляем систему (для Федерации)
var message = await _messageRepository.GetByIdAsync(reaction.MessageId, cancellationToken);
if (message != null)
{
await _mediator.Publish(new MessageReactionAddedDomainEvent(reaction.MessageId, message.ChatId, reaction.UserId, reaction.Emoji), cancellationToken);
}
}
public async Task RemoveAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
@@ -38,6 +49,13 @@ public sealed class MessageReactionRepository : IMessageReactionRepository
);
await _reactions.DeleteOneAsync(filter, cancellationToken);
// Уведомляем систему (для Федерации)
var message = await _messageRepository.GetByIdAsync(messageId, cancellationToken);
if (message != null)
{
await _mediator.Publish(new MessageReactionRemovedDomainEvent(messageId, message.ChatId, userId, emoji), cancellationToken);
}
}
public async Task<List<MessageReaction>> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken)

View File

@@ -129,6 +129,29 @@ public sealed class MessageRepository : IMessageRepository
await _mediator.Publish(domainEvent, cancellationToken);
}
}
public async Task<List<Message>> GetAllMessagesAsync(CancellationToken cancellationToken)
{
return await _messages.Find(_ => true).ToListAsync(cancellationToken);
}
public async Task DeleteChatMessagesAsync(Guid chatId, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.ChatId, chatId);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
public async Task DeleteUserMessagesAsync(Guid userId, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.SenderId, userId);
await _messages.DeleteManyAsync(filter, cancellationToken);
}
public async Task RemoveAsync(Guid id, CancellationToken cancellationToken)
{
var filter = Builders<Message>.Filter.Eq(m => m.Id, id);
await _messages.DeleteOneAsync(filter, cancellationToken);
}
}

View File

@@ -2,7 +2,6 @@ using Knot.Modules.Messaging.Domain;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Messaging.Infrastructure.Persistence.Mongo;
@@ -59,6 +58,17 @@ public static class MongoDbMapConfigurator
BsonClassMap.RegisterClassMap<DeletedMessage>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<MessageReaction>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<PollMessage>(cm =>
{
cm.AutoMap();
cm.MapField("_options").SetElementName("Options");
cm.MapField("_votes").SetElementName("Votes");
cm.SetDiscriminator("PollMessage");
});
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<Media>(cm =>
{