Вторая часть по кэшу

This commit is contained in:
Халимов Рустам
2026-05-08 22:48:56 +03:00
parent d4c41b333a
commit a0b50b57b0
21 changed files with 237 additions and 377 deletions

View File

@@ -3,7 +3,6 @@ using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Logging;
@@ -42,7 +41,6 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator;
private readonly IMessagesSettings _messagesSettings;
private readonly IIdempotencyKeyRepository _idempotencyRepository;
private readonly ILogger<SendMessageCommandHandler> _logger;
public SendMessageCommandHandler(
@@ -51,7 +49,6 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator,
IMessagesSettings messagesSettings,
IIdempotencyKeyRepository idempotencyRepository,
ILogger<SendMessageCommandHandler> logger)
{
_chatRepository = chatRepository;
@@ -59,23 +56,11 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
_unitOfWork = unitOfWork;
_mediator = mediator;
_messagesSettings = messagesSettings;
_idempotencyRepository = idempotencyRepository;
_logger = logger;
}
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{
// 0. Проверка идемпотентности
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
var existingMessageId = await _idempotencyRepository.GetProcessedMessageIdAsync(request.IdempotencyKey, cancellationToken);
if (existingMessageId.HasValue)
{
_logger.LogInformation("Idempotency key already processed: {Key}, returning existing message: {MessageId}", request.IdempotencyKey, existingMessageId.Value);
return Result.Success(existingMessageId.Value);
}
}
// 1. Проверка существования чата
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null)
@@ -217,12 +202,6 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
_messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// 6. Сохранение idempotency ключа после успешного создания сообщения
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
await _idempotencyRepository.SaveKeyAsync(request.IdempotencyKey, message.Id, request.ChatId, cancellationToken);
}
await _mediator.Publish(new MessageSentDomainEvent(
message.Id,

View File

@@ -2,20 +2,11 @@ using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
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;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
namespace Knot.Modules.Conversations;
@@ -42,9 +33,6 @@ public static class DependencyInjection
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
services.AddScoped<Knot.Contracts.Conversations.Domain.IUserFolderSettingsRepository, UserFolderSettingsRepository>();
// Idempotency support
services.AddScoped<IIdempotencyKeyRepository, IdempotencyKeyRepository>();
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));

View File

@@ -1,26 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Conversations.Domain;
/// <summary>
/// Репозиторий для управления idempotency ключами
/// </summary>
public interface IIdempotencyKeyRepository
{
/// <summary>
/// Проверяет наличие ключа и возвращает сообщение если оно уже было обработано
/// </summary>
Task<Guid?> GetProcessedMessageIdAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Сохраняет idempotency ключ и связывает его с сообщением
/// </summary>
Task SaveKeyAsync(string key, Guid messageId, Guid chatId, CancellationToken cancellationToken = default);
/// <summary>
/// Проверяет и сохраняет атомарно (избегаем race condition)
/// Возвращает: MessageId если ключ уже был, null если ключ был сохранен успешно
/// </summary>
Task<Guid?> GetOrSaveKeyAsync(string key, Guid messageId, Guid chatId, CancellationToken cancellationToken = default);
}

View File

@@ -1,57 +0,0 @@
using System;
using MongoDB.Bson.Serialization.Attributes;
namespace Knot.Modules.Conversations.Domain;
/// <summary>
/// Запись об обработанном idempotency ключе для предотвращения дубликатов сообщений.
/// Используется для обеспечения идемпотентности при повторной отправке сообщений офлайн.
/// </summary>
public class IdempotencyKeyRecord
{
[BsonId]
[BsonRepresentation(MongoDB.Bson.BsonType.String)]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Idempotency ключ из заголовка запроса
/// </summary>
[BsonElement("key")]
public string Key { get; set; } = string.Empty;
/// <summary>
/// ID созданного сообщения
/// </summary>
[BsonElement("messageId")]
public Guid MessageId { get; set; }
/// <summary>
/// ID чата
/// </summary>
[BsonElement("chatId")]
public Guid ChatId { get; set; }
/// <summary>
/// Время создания записи
/// </summary>
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Время истечения записи (через 24 часа для очистки устаревших ключей)
/// </summary>
[BsonElement("expiresAt")]
public DateTime ExpiresAt { get; set; }
public IdempotencyKeyRecord() { }
public IdempotencyKeyRecord(string key, Guid messageId, Guid chatId)
{
Id = Guid.NewGuid().ToString();
Key = key;
MessageId = messageId;
ChatId = chatId;
CreatedAt = DateTime.UtcNow;
ExpiresAt = DateTime.UtcNow.AddHours(24);
}
}

View File

@@ -1,112 +0,0 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using MongoDB.Bson;
namespace Knot.Modules.Conversations.Domain;
/// <summary>
/// Реализация репозитория idempotency ключей на основе MongoDB
/// </summary>
public class IdempotencyKeyRepository : IIdempotencyKeyRepository
{
private readonly IMongoCollection<IdempotencyKeyRecord> _collection;
private readonly ILogger<IdempotencyKeyRepository> _logger;
public IdempotencyKeyRepository(IMongoClient mongoClient, ILogger<IdempotencyKeyRepository> logger)
{
var database = mongoClient.GetDatabase("KnotDb");
_collection = database.GetCollection<IdempotencyKeyRecord>("idempotency_keys");
_logger = logger;
// Создаем индекс по ключу для быстрого поиска
CreateIndexes();
}
private void CreateIndexes()
{
var keyIndexModel = new CreateIndexModel<IdempotencyKeyRecord>(
Builders<IdempotencyKeyRecord>.IndexKeys.Ascending(x => x.Key),
new CreateIndexOptions { Unique = true }
);
var expireIndexModel = new CreateIndexModel<IdempotencyKeyRecord>(
Builders<IdempotencyKeyRecord>.IndexKeys.Ascending(x => x.ExpiresAt),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero } // TTL индекс
);
_collection.Indexes.CreateOne(keyIndexModel);
_collection.Indexes.CreateOne(expireIndexModel);
}
public async Task<Guid?> GetProcessedMessageIdAsync(string key, CancellationToken cancellationToken = default)
{
try
{
var record = await _collection
.Find(x => x.Key == key)
.FirstOrDefaultAsync(cancellationToken);
return record?.MessageId;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting processed message id for key: {Key}", key);
return null;
}
}
public async Task SaveKeyAsync(string key, Guid messageId, Guid chatId, CancellationToken cancellationToken = default)
{
try
{
var record = new IdempotencyKeyRecord(key, messageId, chatId);
await _collection.InsertOneAsync(record, cancellationToken: cancellationToken);
_logger.LogDebug("Saved idempotency key: {Key} for message: {MessageId}", key, messageId);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Ключ уже существует - это нормально, игнорируем
_logger.LogDebug("Idempotency key already exists: {Key}", key);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving idempotency key: {Key}", key);
throw;
}
}
public async Task<Guid?> GetOrSaveKeyAsync(string key, Guid messageId, Guid chatId, CancellationToken cancellationToken = default)
{
try
{
// Пробуем найти существующий ключ
var existingRecord = await _collection
.Find(x => x.Key == key)
.FirstOrDefaultAsync(cancellationToken);
if (existingRecord != null)
{
_logger.LogDebug("Idempotency key already processed: {Key}, returning existing message: {MessageId}", key, existingRecord.MessageId);
return existingRecord.MessageId;
}
// Пробуем вставить новую запись
var record = new IdempotencyKeyRecord(key, messageId, chatId);
await _collection.InsertOneAsync(record, cancellationToken: cancellationToken);
_logger.LogDebug("Saved new idempotency key: {Key} for message: {MessageId}", key, messageId);
return null;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Race condition: другой запрос успел сохранить ключ
// Повторяем поиск
_logger.LogDebug("Race condition on idempotency key: {Key}, retrying", key);
return await GetProcessedMessageIdAsync(key, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in GetOrSaveKey for: {Key}", key);
throw;
}
}
}

View File

@@ -29,6 +29,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="MongoDB.Driver" Version="3.2.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="DKNet.AspCore.Idempotency" Version="1.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,69 +0,0 @@
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Presentation.Middleware;
/// <summary>
/// Middleware для проверки идемпотентности POST-запросов к сообщениям
/// </summary>
public class IdempotencyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<IdempotencyMiddleware> _logger;
public IdempotencyMiddleware(RequestDelegate next, ILogger<IdempotencyMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, IIdempotencyKeyRepository idempotencyRepository)
{
// Обрабатываем только POST запросы к /api/messages/chat/
if (context.Request.Method == HttpMethods.Post &&
context.Request.Path.StartsWithSegments("/api/messages/chat/"))
{
if (context.Request.Headers.TryGetValue("X-Idempotency-Key", out var idempotencyKey))
{
var key = idempotencyKey.ToString().Trim();
if (!string.IsNullOrWhiteSpace(key))
{
_logger.LogDebug("Processing idempotency key: {Key}", key);
// Проверяем, был ли уже обработан этот ключ
var existingMessageId = await idempotencyRepository.GetProcessedMessageIdAsync(key);
if (existingMessageId.HasValue)
{
_logger.LogInformation("Returning cached response for idempotency key: {Key}, MessageId: {MessageId}", key, existingMessageId.Value);
// Возвращаем успешный ответ с ID существующего сообщения
context.Response.StatusCode = (int)HttpStatusCode.OK;
context.Response.ContentType = "application/json";
var response = JsonSerializer.Serialize(new { id = existingMessageId.Value.ToString() });
await context.Response.WriteAsync(response);
return;
}
}
}
}
await _next(context);
}
}
// Extension method для упрощения использования
public static class IdempotencyMiddlewareExtensions
{
public static IApplicationBuilder UseIdempotencyMiddleware(this IApplicationBuilder builder)
{
return builder.UseMiddleware<IdempotencyMiddleware>();
}
}