Фиксы

This commit is contained in:
Халимов Рустам
2026-05-08 23:22:07 +03:00
parent a0b50b57b0
commit 00ed4b5959
12 changed files with 120 additions and 57 deletions
@@ -7,9 +7,8 @@ using FluentAssertions;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.Chats.Create; using Knot.Modules.Conversations.Application.Chats.Create;
namespace Knot.Modules.Conversations.UnitTests.Chats; namespace Knot.Modules.Conversations.UnitTests.Chats;
@@ -7,9 +7,10 @@ using FluentAssertions;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Modules.Conversations.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Messaging.Domain; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.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.Chats.GetChats;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
@@ -1,9 +1,8 @@
using FluentAssertions; using FluentAssertions;
using NSubstitute; 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.Application.Chats.GetOrCreateFavorites;
using Knot.Modules.Conversations.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Messaging.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Xunit; using Xunit;
@@ -23,7 +23,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\src\Modules\Chats\Knot.Modules.Conversations.csproj" /> <ProjectReference Include="..\..\..\src\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" /> <ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" /> <ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" />
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" /> <ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
@@ -5,15 +5,17 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentAssertions; using FluentAssertions;
using MediatR; using MediatR;
using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using Xunit; 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.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.Conversations.Application.Messages.Send;
using Knot.Modules.Settings.Application.Settings.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Modules.Settings.Application.Settings.DTOs; using Knot.Contracts.Settings.Application.DTOs;
namespace Knot.Modules.Conversations.UnitTests.Messages; namespace Knot.Modules.Conversations.UnitTests.Messages;
@@ -24,6 +26,8 @@ public class SendMessageCommandHandlerTests
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IMessagesSettings _messagesSettings; private readonly IMessagesSettings _messagesSettings;
private readonly IIdempotencyStore _idempotencyStore;
private readonly ILogger<SendMessageCommandHandler> _logger;
private readonly SendMessageCommandHandler _handler; private readonly SendMessageCommandHandler _handler;
public SendMessageCommandHandlerTests() public SendMessageCommandHandlerTests()
@@ -33,11 +37,13 @@ public class SendMessageCommandHandlerTests
_unitOfWork = Substitute.For<IChatsUnitOfWork>(); _unitOfWork = Substitute.For<IChatsUnitOfWork>();
_mediator = Substitute.For<IMediator>(); _mediator = Substitute.For<IMediator>();
_messagesSettings = Substitute.For<IMessagesSettings>(); _messagesSettings = Substitute.For<IMessagesSettings>();
_idempotencyStore = Substitute.For<IIdempotencyStore>();
_logger = Substitute.For<ILogger<SendMessageCommandHandler>>();
var config = new Knot.Modules.Settings.Application.Settings.DTOs.MessagesConfig(); var config = new MessagesConfig();
_messagesSettings.Current.Returns(config); _messagesSettings.Current.Returns(config);
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings); _handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings, _idempotencyStore, _logger);
} }
[Fact] [Fact]
@@ -0,0 +1,17 @@
namespace Knot.Contracts.Conversations.Application.Abstractions;
/// <summary>
/// Хранилище для обеспечения идемпотентности операций.
/// Если ключ уже существует — возвращает сохранённый результат без повторного выполнения.
/// </summary>
public interface IIdempotencyStore
{
/// <summary>
/// Возвращает сохранённый результат по ключу или выполняет factory, сохраняет и возвращает результат.
/// </summary>
Task<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> factory,
TimeSpan? expiration = null,
CancellationToken cancellationToken = default);
}
-1
View File
@@ -24,7 +24,6 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" /> <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
<PackageReference Include="DKNet.AspCore.Idempotency" Version="1.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
-3
View File
@@ -12,7 +12,6 @@ using Knot.Modules.Conversations;
using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.SignalR; using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Modules.Conversations.Presentation.Endpoints; using Knot.Modules.Conversations.Presentation.Endpoints;
using Knot.Modules.Conversations.Presentation.Middleware;
using Knot.Modules.Federation; using Knot.Modules.Federation;
using Knot.Modules.Federation.Presentation.Endpoints; using Knot.Modules.Federation.Presentation.Endpoints;
using Knot.Modules.Klipy; using Knot.Modules.Klipy;
@@ -141,7 +140,6 @@ builder.Services.AddRouting(options =>
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddIdempotency(); // DKNet.AspCore.Idempotency DI registration
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options =>
{ {
@@ -244,7 +242,6 @@ if (app.Environment.IsDevelopment())
// Не раздаем статические файлы, так как теперь используем MinIO // Не раздаем статические файлы, так как теперь используем MinIO
app.UseAuthentication(); app.UseAuthentication();
app.UseIdempotency(); // DKNet.AspCore.Idempotency middleware
app.UseAuthorization(); app.UseAuthorization();
// Регистрация эндпоинтов // Регистрация эндпоинтов
@@ -41,6 +41,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator; private readonly MediatR.IMediator _mediator;
private readonly IMessagesSettings _messagesSettings; private readonly IMessagesSettings _messagesSettings;
private readonly IIdempotencyStore _idempotencyStore;
private readonly ILogger<SendMessageCommandHandler> _logger; private readonly ILogger<SendMessageCommandHandler> _logger;
public SendMessageCommandHandler( public SendMessageCommandHandler(
@@ -49,6 +50,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
IChatsUnitOfWork unitOfWork, IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator, MediatR.IMediator mediator,
IMessagesSettings messagesSettings, IMessagesSettings messagesSettings,
IIdempotencyStore idempotencyStore,
ILogger<SendMessageCommandHandler> logger) ILogger<SendMessageCommandHandler> logger)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
@@ -56,10 +58,25 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mediator = mediator; _mediator = mediator;
_messagesSettings = messagesSettings; _messagesSettings = messagesSettings;
_idempotencyStore = idempotencyStore;
_logger = logger; _logger = logger;
} }
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken) public async Task<Result<Guid>> 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<Result<Guid>> ExecuteAsync(SendMessageCommand request, CancellationToken cancellationToken)
{ {
// 1. Проверка существования чата // 1. Проверка существования чата
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken); var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
@@ -68,40 +85,30 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
return Result.Failure<Guid>(ChatErrors.ChatsNotFound); return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
} }
// 2. , // 2. Проверка, является ли отправитель участником чата
if (!chat.Members.Any(m => m.UserId == request.SenderId)) if (!chat.Members.Any(m => m.UserId == request.SenderId))
{ {
return Result.Failure<Guid>(ChatErrors.ChatsForbidden); return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
} }
// 3. // 3. Создание сообщения
Message message; Message message;
if (request.Type == "story_reply" || request.Type == "story_reaction") if (request.Type == "story_reply" || request.Type == "story_reaction")
{ {
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled); if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image; var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
message = new StoryMessage( message = new StoryMessage(
Guid.NewGuid(), Guid.NewGuid(),
request.ChatId, request.ChatId,
request.SenderId, request.SenderId,
request.StoryId ?? Guid.Empty, request.StoryId ?? Guid.Empty,
request.StoryMediaUrl ?? string.Empty, request.StoryMediaUrl ?? string.Empty,
request.StoryMediaType, request.StoryMediaType,
request.Content, request.Content,
request.ReplyToId, request.ReplyToId,
request.ForwardedFromId, request.ForwardedFromId,
DateTime.UtcNow, DateTime.UtcNow,
false); false);
} }
else if (request.Attachments != null && request.Attachments.Any()) else if (request.Attachments != null && request.Attachments.Any())
@@ -111,26 +118,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var firstAtt = request.Attachments.First(); var firstAtt = request.Attachments.First();
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File; var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
message = new MediaMessage( message = new MediaMessage(
Guid.NewGuid(), Guid.NewGuid(),
request.ChatId, request.ChatId,
request.SenderId, request.SenderId,
parsedType, parsedType,
request.Content, request.Content,
request.ReplyToId, request.ReplyToId,
request.ForwardedFromId, request.ForwardedFromId,
DateTime.UtcNow, DateTime.UtcNow,
false); false);
foreach (var att in request.Attachments) foreach (var att in request.Attachments)
{ {
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File; var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
@@ -172,25 +170,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
{ {
message = new TextMessage( message = new TextMessage(
Guid.NewGuid(), Guid.NewGuid(),
request.ChatId, request.ChatId,
request.SenderId, request.SenderId,
request.Content ?? string.Empty, request.Content ?? string.Empty,
request.ReplyToId, request.ReplyToId,
request.Quote, request.Quote,
request.ForwardedFromId, request.ForwardedFromId,
DateTime.UtcNow, DateTime.UtcNow,
false); false);
} }
// 4. High-Water Mark // 4. Обновление High-Water Mark
chat.IncrementSequenceId(); chat.IncrementSequenceId();
message.SetSequenceId(chat.LastMessageSequenceId); message.SetSequenceId(chat.LastMessageSequenceId);
@@ -204,13 +194,9 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
await _mediator.Publish(new MessageSentDomainEvent( await _mediator.Publish(new MessageSentDomainEvent(
message.Id, message.Id,
message.ChatId, message.ChatId,
message.SenderId, message.SenderId,
message.Content), message.Content),
cancellationToken); cancellationToken);
return Result.Success(message.Id); return Result.Success(message.Id);
@@ -2,7 +2,9 @@ using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence; using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.Idempotency;
using Knot.Modules.Conversations.Infrastructure.Persistence; using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
using Knot.Modules.Conversations.Infrastructure.Services; using Knot.Modules.Conversations.Infrastructure.Services;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -42,6 +44,9 @@ public static class DependencyInjection
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>(); services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>(); services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
services.AddMemoryCache();
services.AddSingleton<Knot.Contracts.Conversations.Application.Abstractions.IIdempotencyStore, MemoryCacheIdempotencyStore>();
return services; return services;
} }
} }
@@ -0,0 +1,54 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Microsoft.Extensions.Caching.Memory;
namespace Knot.Modules.Conversations.Infrastructure.Idempotency;
/// <summary>
/// Реализация хранилища идемпотентности на основе IMemoryCache.
/// Использует семафор для предотвращения race condition при одновременных запросах с одинаковым ключом.
/// </summary>
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<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, Task<T>> 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();
}
}
}
@@ -29,7 +29,6 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="MongoDB.Driver" Version="3.2.0" /> <PackageReference Include="MongoDB.Driver" Version="3.2.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="DKNet.AspCore.Idempotency" Version="1.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -38,6 +37,7 @@
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" /> <InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
<InternalsVisibleTo Include="Knot.Modules.Conversations.UnitTests" />
</ItemGroup> </ItemGroup>
</Project> </Project>