diff --git a/backend/Tests/Conversations/UnitTests/Chats/CreateChatCommandHandlerTests.cs b/backend/Tests/Conversations/UnitTests/Chats/CreateChatCommandHandlerTests.cs index 4de4ea6..321faa1 100644 --- a/backend/Tests/Conversations/UnitTests/Chats/CreateChatCommandHandlerTests.cs +++ b/backend/Tests/Conversations/UnitTests/Chats/CreateChatCommandHandlerTests.cs @@ -7,9 +7,8 @@ using FluentAssertions; using NSubstitute; using Xunit; using Knot.Shared.Kernel; -using Knot.Modules.Conversations.Domain; -using Knot.Modules.Messaging.Domain; -using Knot.Modules.Conversations.Application.Abstractions; +using Knot.Contracts.Conversations.Domain; +using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Chats.Create; namespace Knot.Modules.Conversations.UnitTests.Chats; diff --git a/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs b/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs index a009178..af3b7b9 100644 --- a/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs +++ b/backend/Tests/Conversations/UnitTests/Chats/GetChatsQueryHandlerTests.cs @@ -7,9 +7,10 @@ using FluentAssertions; using NSubstitute; using Xunit; using Knot.Shared.Kernel; -using Knot.Modules.Conversations.Domain; -using Knot.Modules.Messaging.Domain; -using Knot.Modules.Conversations.Application.Abstractions; +using Knot.Contracts.Conversations.Domain; +using Knot.Contracts.Conversations.Application.Abstractions; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using Knot.Modules.Conversations.Application.Chats.GetChats; using Knot.Modules.Conversations.Application.DTOs; diff --git a/backend/Tests/Conversations/UnitTests/GetOrCreateFavoritesCommandHandlerTests.cs b/backend/Tests/Conversations/UnitTests/GetOrCreateFavoritesCommandHandlerTests.cs index 0368f51..477703e 100644 --- a/backend/Tests/Conversations/UnitTests/GetOrCreateFavoritesCommandHandlerTests.cs +++ b/backend/Tests/Conversations/UnitTests/GetOrCreateFavoritesCommandHandlerTests.cs @@ -1,9 +1,8 @@ using FluentAssertions; using NSubstitute; -using Knot.Modules.Conversations.Application.Abstractions; +using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites; -using Knot.Modules.Conversations.Domain; -using Knot.Modules.Messaging.Domain; +using Knot.Contracts.Conversations.Domain; using Knot.Shared.Kernel; using Xunit; diff --git a/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj b/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj index 11589b9..43497f2 100644 --- a/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj +++ b/backend/Tests/Conversations/UnitTests/Knot.Modules.Conversations.UnitTests.csproj @@ -23,10 +23,10 @@ - + - + diff --git a/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs b/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs index 8e5192a..1a36c48 100644 --- a/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs +++ b/backend/Tests/Conversations/UnitTests/Messages/SendMessageCommandHandlerTests.cs @@ -5,15 +5,17 @@ using System.Threading; using System.Threading.Tasks; using FluentAssertions; using MediatR; +using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Knot.Contracts.Conversations.Application.Abstractions; +using Knot.Contracts.Conversations.Domain; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; using Knot.Shared.Kernel; -using Knot.Modules.Conversations.Domain; -using Knot.Modules.Messaging.Domain; -using Knot.Modules.Conversations.Application.Abstractions; using Knot.Modules.Conversations.Application.Messages.Send; -using Knot.Modules.Settings.Application.Settings.Abstractions; -using Knot.Modules.Settings.Application.Settings.DTOs; +using Knot.Contracts.Settings.Application.Abstractions; +using Knot.Contracts.Settings.Application.DTOs; namespace Knot.Modules.Conversations.UnitTests.Messages; @@ -24,6 +26,8 @@ public class SendMessageCommandHandlerTests private readonly IChatsUnitOfWork _unitOfWork; private readonly IMediator _mediator; private readonly IMessagesSettings _messagesSettings; + private readonly IIdempotencyStore _idempotencyStore; + private readonly ILogger _logger; private readonly SendMessageCommandHandler _handler; public SendMessageCommandHandlerTests() @@ -33,11 +37,13 @@ public class SendMessageCommandHandlerTests _unitOfWork = Substitute.For(); _mediator = Substitute.For(); _messagesSettings = Substitute.For(); + _idempotencyStore = Substitute.For(); + _logger = Substitute.For>(); - var config = new Knot.Modules.Settings.Application.Settings.DTOs.MessagesConfig(); + var config = new MessagesConfig(); _messagesSettings.Current.Returns(config); - _handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings); + _handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings, _idempotencyStore, _logger); } [Fact] diff --git a/backend/src/Contracts/Conversations/Application/Abstractions/IIdempotencyStore.cs b/backend/src/Contracts/Conversations/Application/Abstractions/IIdempotencyStore.cs new file mode 100644 index 0000000..ddc7057 --- /dev/null +++ b/backend/src/Contracts/Conversations/Application/Abstractions/IIdempotencyStore.cs @@ -0,0 +1,17 @@ +namespace Knot.Contracts.Conversations.Application.Abstractions; + +/// +/// Хранилище для обеспечения идемпотентности операций. +/// Если ключ уже существует — возвращает сохранённый результат без повторного выполнения. +/// +public interface IIdempotencyStore +{ + /// + /// Возвращает сохранённый результат по ключу или выполняет factory, сохраняет и возвращает результат. + /// + Task GetOrCreateAsync( + string key, + Func> factory, + TimeSpan? expiration = null, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/Host/Host.csproj b/backend/src/Host/Host.csproj index a6740d5..746b68c 100644 --- a/backend/src/Host/Host.csproj +++ b/backend/src/Host/Host.csproj @@ -24,7 +24,6 @@ - diff --git a/backend/src/Host/Program.cs b/backend/src/Host/Program.cs index 434d696..3c8b04d 100644 --- a/backend/src/Host/Program.cs +++ b/backend/src/Host/Program.cs @@ -12,7 +12,6 @@ using Knot.Modules.Conversations; using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.SignalR; using Knot.Modules.Conversations.Presentation.Endpoints; -using Knot.Modules.Conversations.Presentation.Middleware; using Knot.Modules.Federation; using Knot.Modules.Federation.Presentation.Endpoints; using Knot.Modules.Klipy; @@ -141,7 +140,6 @@ builder.Services.AddRouting(options => builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); -builder.Services.AddIdempotency(); // DKNet.AspCore.Idempotency DI registration builder.Services.ConfigureHttpJsonOptions(options => { @@ -244,7 +242,6 @@ if (app.Environment.IsDevelopment()) // Не раздаем статические файлы, так как теперь используем MinIO app.UseAuthentication(); -app.UseIdempotency(); // DKNet.AspCore.Idempotency middleware app.UseAuthorization(); // Регистрация эндпоинтов diff --git a/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs b/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs index 2026c44..1e45269 100644 --- a/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs +++ b/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs @@ -41,6 +41,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler _logger; public SendMessageCommandHandler( @@ -49,6 +50,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler logger) { _chatRepository = chatRepository; @@ -56,10 +58,25 @@ public sealed class SendMessageCommandHandler : ICommandHandler> Handle(SendMessageCommand request, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) + { + var key = $"send_msg:{request.ChatId}:{request.IdempotencyKey}"; + return await _idempotencyStore.GetOrCreateAsync( + key, + factory: ct => ExecuteAsync(request, ct), + cancellationToken: cancellationToken); + } + + return await ExecuteAsync(request, cancellationToken); + } + + private async Task> ExecuteAsync(SendMessageCommand request, CancellationToken cancellationToken) { // 1. Проверка существования чата var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken); @@ -68,40 +85,30 @@ public sealed class SendMessageCommandHandler : ICommandHandler(ChatErrors.ChatsNotFound); } - // 2. ���������, �������� �� ����������� ���������� + // 2. Проверка, является ли отправитель участником чата if (!chat.Members.Any(m => m.UserId == request.SenderId)) { return Result.Failure(ChatErrors.ChatsForbidden); } - // 3. ������� ��������� + // 3. Создание сообщения Message message; if (request.Type == "story_reply" || request.Type == "story_reaction") { if (!_messagesSettings.Current.AllowMedia) return Result.Failure(ChatErrors.MediaDisabled); - var parsedStoryMediaType = Enum.TryParse(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image; message = new StoryMessage( Guid.NewGuid(), - request.ChatId, - request.SenderId, - request.StoryId ?? Guid.Empty, - request.StoryMediaUrl ?? string.Empty, request.StoryMediaType, - request.Content, - request.ReplyToId, - request.ForwardedFromId, - DateTime.UtcNow, - false); } else if (request.Attachments != null && request.Attachments.Any()) @@ -111,26 +118,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File; - message = new MediaMessage( Guid.NewGuid(), - request.ChatId, - request.SenderId, parsedType, - request.Content, - request.ReplyToId, - request.ForwardedFromId, - DateTime.UtcNow, - false); - foreach (var att in request.Attachments) { var pType = Enum.TryParse(att.Type, true, out var tEnum) ? tEnum : MediaType.File; @@ -172,25 +170,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler(); services.AddScoped(); + services.AddMemoryCache(); + services.AddSingleton(); + return services; } } diff --git a/backend/src/Modules/Conversations/Infrastructure/Idempotency/MemoryCacheIdempotencyStore.cs b/backend/src/Modules/Conversations/Infrastructure/Idempotency/MemoryCacheIdempotencyStore.cs new file mode 100644 index 0000000..a77b51d --- /dev/null +++ b/backend/src/Modules/Conversations/Infrastructure/Idempotency/MemoryCacheIdempotencyStore.cs @@ -0,0 +1,54 @@ +using Knot.Contracts.Conversations.Application.Abstractions; +using Microsoft.Extensions.Caching.Memory; + +namespace Knot.Modules.Conversations.Infrastructure.Idempotency; + +/// +/// Реализация хранилища идемпотентности на основе IMemoryCache. +/// Использует семафор для предотвращения race condition при одновременных запросах с одинаковым ключом. +/// +public sealed class MemoryCacheIdempotencyStore : IIdempotencyStore +{ + private readonly IMemoryCache _cache; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + public MemoryCacheIdempotencyStore(IMemoryCache cache) + { + _cache = cache; + } + + public async Task GetOrCreateAsync( + string key, + Func> factory, + TimeSpan? expiration = null, + CancellationToken cancellationToken = default) + { + if (_cache.TryGetValue(key, out T? cachedValue) && cachedValue is not null) + { + return cachedValue; + } + + await _semaphore.WaitAsync(cancellationToken); + try + { + // Double-check после получения блокировки + if (_cache.TryGetValue(key, out cachedValue) && cachedValue is not null) + { + return cachedValue; + } + + var value = await factory(cancellationToken); + + var options = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(expiration ?? TimeSpan.FromHours(24)) + .SetPriority(CacheItemPriority.Normal); + + _cache.Set(key, value, options); + return value; + } + finally + { + _semaphore.Release(); + } + } +} diff --git a/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj b/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj index 6c6891e..162d33e 100644 --- a/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj +++ b/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj @@ -29,7 +29,6 @@ - @@ -38,6 +37,7 @@ +