Заготовка

This commit is contained in:
Халимов Рустам
2026-05-07 23:27:37 +03:00
parent 2d2e4b685e
commit 9daa786cfb
33 changed files with 1662 additions and 178 deletions

View File

@@ -1,9 +1,11 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Logging;
namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -30,7 +32,8 @@ public sealed record SendMessageCommand(
DateTime? PollExpiresAt = null,
string? CallType = null,
string? CallStatus = null,
int? Duration = null) : ICommand<Guid>;
int? Duration = null,
string? IdempotencyKey = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{
@@ -39,24 +42,41 @@ 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(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator,
IMessagesSettings messagesSettings)
IMessagesSettings messagesSettings,
IIdempotencyKeyRepository idempotencyRepository,
ILogger<SendMessageCommandHandler> logger)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_mediator = mediator;
_messagesSettings = messagesSettings;
_idempotencyRepository = idempotencyRepository;
_logger = logger;
}
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{
// 1. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
// 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)
{
@@ -193,10 +213,16 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id);
// 5. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
// 5. Сохранение
_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

@@ -1,14 +1,22 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
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;
using Knot.Contracts.Messaging.Application.Abstractions;
namespace Knot.Modules.Conversations;
@@ -34,6 +42,9 @@ 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));
@@ -42,7 +53,7 @@ public static class DependencyInjection
services.AddScoped<Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
return services;
}
}

View File

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,57 @@
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

@@ -0,0 +1,112 @@
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

@@ -55,11 +55,14 @@ public static class MessagesEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, HttpRequest httpRequest, CancellationToken ct) =>
{
var attachments = request.Attachments?.Select(a =>
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
// Получаем idempotency ключ из заголовка
httpRequest.Headers.TryGetValue("X-Idempotency-Key", out var idempotencyKey);
var command = new SendMessageCommand(
chatId,
userContext.UserId,
@@ -68,7 +71,8 @@ public static class MessagesEndpoints
attachments,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
request.ForwardedFromId,
IdempotencyKey: idempotencyKey.ToString());
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);

View File

@@ -0,0 +1,69 @@
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>();
}
}