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 @@
+