Заготовка
This commit is contained in:
@@ -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,
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user