Структура, доп модули, федерация, документация
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
156
backend/src/Modules/Messaging/Domain/PollMessage.cs
Normal file
156
backend/src/Modules/Messaging/Domain/PollMessage.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user