refactor(im): 将 MongoDB 访问方式从原生驱动改为 MongoDB.Entities

- 移除 MongoDbContext 和 IMongoClient 依赖注入配置
- 引入 MongoDB.Entities 库替代原生 MongoDB 驱动
- 使用 DB.Default 静态方法进行数据库操作
- 更新消息文档模型继承 Entity 基类
- 修改索引创建方式为 MongoDB.Entities 格式
- 调整消息历史查询和撤回功能适配新 API
- 更新消息发送和 AI 消息处理的数据访问逻辑
This commit is contained in:
xiangning 2026-07-03 11:46:43 +08:00
parent 69593b2b5f
commit 47c45bd15f
8 changed files with 52 additions and 96 deletions

View File

@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using MongoDB.Entities;
using Workflow.Infrastructure.Persistence;
using RagSeedData = BuildFlow.Infrastructure.Persistence.SeedData;
using PlatformSeedData = BuildFlow.Platform.Infrastructure.Persistence.SeedData;
@ -37,13 +38,19 @@ public static class DatabaseInitializer
await MigrateAsync<ImDbContext>(sp, logger);
await MigrateAsync<WorkflowDbContext>(sp, logger, suppressPendingChanges: true);
// 2. IM MongoDB 建索引(可选,Mongo 不可用时跳过)
// 2. IM MongoDB 建索引(MongoDB.Entities:用 DB.Index<MessageDocument> 创建两个复合索引)
// 两个复合索引支撑两种查询路径:按时间倒序拉取最新消息、按 ObjectId 游标分页上拉更早消息。
await SafeRunAsync(
async () =>
{
var mongoContext = sp.GetService<MongoDbContext>();
if (mongoContext is not null)
await mongoContext.CreateIndexesAsync();
await DB.Default.Index<MessageDocument>()
.Key(m => m.ConversationId, KeyType.Ascending)
.Key(m => m.CreatedAt, KeyType.Descending)
.CreateAsync();
await DB.Default.Index<MessageDocument>()
.Key(m => m.ConversationId, KeyType.Ascending)
.Key(m => m.ID, KeyType.Descending)
.CreateAsync();
},
logger,
"IM MongoDB 建索引");

View File

@ -5,7 +5,7 @@ using IM.Domain.Common;
using IM.Infrastructure.Mongo;
using IM.Infrastructure.Persistence;
using MediatR;
using MongoDB.Driver;
using MongoDB.Entities;
namespace IM.Application.Chat.Handlers;
@ -14,7 +14,6 @@ namespace IM.Application.Chat.Handlers;
/// </summary>
public class GetMessageHistoryQueryHandler(
ImDbContext db,
MongoDbContext mongoDb,
IUserInfoService userInfoService) : IRequestHandler<GetMessageHistoryQuery, MessageHistoryDto>
{
public async Task<MessageHistoryDto> Handle(GetMessageHistoryQuery request, CancellationToken ct)
@ -26,21 +25,20 @@ public class GetMessageHistoryQueryHandler(
if (!isParticipant)
throw new UnauthorizedException("无权查看该会话的消息");
var filter = Builders<MessageDocument>.Filter.Eq(m => m.ConversationId, request.ConversationId);
var query = DB.Default.Find<MessageDocument>()
.Match(m => m.ConversationId == request.ConversationId);
// 游标条件:ObjectId 大致按时间单调递增,故 Lt(Id, BeforeId) 等价于"取该游标之前的消息"
// 游标条件:ObjectId 大致按时间单调递增,故 ID < BeforeId 等价于"取该游标之前的消息"
if (!string.IsNullOrEmpty(request.BeforeId))
{
var beforeFilter = Builders<MessageDocument>.Filter.Lt(m => m.Id, request.BeforeId);
filter = Builders<MessageDocument>.Filter.And(filter, beforeFilter);
query = query.Match(m => m.ID.CompareTo(request.BeforeId) < 0);
}
// Limit+1 技巧:多取一条用于判断是否还有更早的消息(HasMore),无需额外 COUNT 查询
var messages = await mongoDb.Messages
.Find(filter)
.SortByDescending(m => m.CreatedAt)
var messages = await query
.Sort(m => m.CreatedAt, Order.Descending)
.Limit(request.Limit + 1)
.ToListAsync(ct);
.ExecuteAsync(ct);
var hasMore = messages.Count > request.Limit;
if (hasMore)
@ -66,7 +64,7 @@ public class GetMessageHistoryQueryHandler(
}
return new MessageDto(
m.Id,
m.ID,
m.ConversationId,
m.SenderId,
m.SenderName,

View File

@ -3,7 +3,7 @@ using IM.Application.Chat.Commands;
using IM.Infrastructure.Mongo;
using IM.Infrastructure.Persistence;
using MediatR;
using MongoDB.Driver;
using MongoDB.Entities;
namespace IM.Application.Chat.Handlers;
@ -19,7 +19,7 @@ namespace IM.Application.Chat.Handlers;
/// </list>
/// 副作用:更新 Mongo 文档 IsRecalled=true;若撤回的是会话最后一条,更新会话摘要为"[消息已撤回]"。
/// </remarks>
public class RecallMessageCommandHandler(ImDbContext db, MongoDbContext mongoDb) : IRequestHandler<RecallMessageCommand, Unit>
public class RecallMessageCommandHandler(ImDbContext db) : IRequestHandler<RecallMessageCommand, Unit>
{
/// <summary>发送者本人撤回的时间窗(分钟)。超时后本人不可撤回,仅管理员可。</summary>
public const int RecallTimeWindowMinutes = 2;
@ -27,10 +27,10 @@ public class RecallMessageCommandHandler(ImDbContext db, MongoDbContext mongoDb)
public async Task<Unit> Handle(RecallMessageCommand request, CancellationToken ct)
{
// 查消息原文(用于校验发送者、创建时间)
var message = await mongoDb.Messages
.Find(m => m.Id == request.MessageId && m.ConversationId == request.ConversationId)
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException("消息不存在");
var messages = await DB.Default.Find<MessageDocument>()
.Match(m => m.ID == request.MessageId && m.ConversationId == request.ConversationId)
.ExecuteAsync(ct);
var message = messages.FirstOrDefault() ?? throw new NotFoundException("消息不存在");
if (message.IsRecalled)
throw new BusinessException("该消息已撤回");
@ -48,14 +48,13 @@ public class RecallMessageCommandHandler(ImDbContext db, MongoDbContext mongoDb)
throw new BusinessException($"消息发送超过 {RecallTimeWindowMinutes} 分钟,无法撤回");
}
// 更新 Mongo:标记撤回
var update = Builders<MessageDocument>.Update
.Set(m => m.IsRecalled, true)
.Set(m => m.RecalledAt, DateTime.UtcNow)
.Set(m => m.RecalledBy, request.OperatorId);
await mongoDb.Messages.UpdateOneAsync(
Builders<MessageDocument>.Filter.Eq(m => m.Id, request.MessageId),
update, cancellationToken: ct);
// 更新 Mongo:标记撤回(按主键定位,逐字段 Modify,无需构造 Builders)
await DB.Default.Update<MessageDocument>()
.MatchID(request.MessageId)
.Modify(m => m.IsRecalled, true)
.Modify(m => m.RecalledAt, DateTime.UtcNow)
.Modify(m => m.RecalledBy, request.OperatorId)
.ExecuteAsync(ct);
// 若撤回的是会话最后一条消息,更新摘要为撤回提示,避免列表显示已撤回内容
var conversation = await db.Conversations.FirstOrDefaultAsync(c => c.Id == request.ConversationId, ct);

View File

@ -7,6 +7,7 @@ using IM.Domain.Enums;
using IM.Infrastructure.Mongo;
using IM.Infrastructure.Persistence;
using MediatR;
using MongoDB.Entities;
namespace IM.Application.Chat.Handlers;
@ -15,7 +16,6 @@ namespace IM.Application.Chat.Handlers;
/// </summary>
public class SendMessageCommandHandler(
ImDbContext db,
MongoDbContext mongoDb,
IUserInfoService userInfoService,
IUnreadCounter unreadCounter,
IOnlinePresenceService onlinePresence,
@ -76,12 +76,12 @@ public class SendMessageCommandHandler(
CreatedAt = DateTime.UtcNow
};
// ① 写 MongoDB:消息正文持久化,InsertOne 后自动生成 ObjectId 作为消息主键
await mongoDb.Messages.InsertOneAsync(message, cancellationToken: ct);
// ① 写 MongoDB:消息正文持久化,SaveAsync 后自动生成 ObjectId 作为消息主键(ID)
await DB.Default.SaveAsync(message, ct);
// ② 写 PostgreSQL:仅同步会话列表摘要与会话级"最后一条消息"指针(读扩散热数据)。
// 注意:未读数累加已移出此事务(见下文 ③),避免大群发消息时的 PG 行锁竞争与写放大。
conversation.LastMessageId = message.Id;
conversation.LastMessageId = message.ID;
conversation.LastMessageContent = BuildLastMessageContent(request.ContentType, request.Content, request.Metadata);
conversation.LastMessageAt = DateTime.UtcNow;
@ -125,7 +125,7 @@ public class SendMessageCommandHandler(
await TryTriggerAiBotAsync(request, conversation, ct);
return new MessageDto(
message.Id,
message.ID,
message.ConversationId,
message.SenderId,
request.SenderName,
@ -184,10 +184,10 @@ public class SendMessageCommandHandler(
ReplyToId = null,
CreatedAt = DateTime.UtcNow
};
await mongoDb.Messages.InsertOneAsync(aiMessage, cancellationToken: ct);
await DB.Default.SaveAsync(aiMessage, ct);
// 更新会话摘要
conversation.LastMessageId = aiMessage.Id;
conversation.LastMessageId = aiMessage.ID;
conversation.LastMessageContent = $"[AI] {Truncate(answer, MaxLastMessageContentLength - 5)}";
conversation.LastMessageAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);

View File

@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using MongoDB.Entities;
using Volo.Abp.Modularity;
namespace IM.Infrastructure;
@ -33,15 +34,12 @@ public class IMInfrastructureModule : AbpModule
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
services.AddScoped<AuditInterceptor>();
// MongoDB:消息正文。IMongoClient 单例复用连接池,MongoDbContext Scoped 保证每次请求独立上下文
services.AddSingleton<IMongoClient>(_ =>
new MongoClient(config.GetConnectionString("MongoDb")));
services.AddScoped(sp =>
{
var client = sp.GetRequiredService<IMongoClient>();
var dbName = MongoUrl.Create(config.GetConnectionString("MongoDb")!).DatabaseName;
return new Mongo.MongoDbContext(client, dbName);
});
// MongoDB(MongoDB.Entities):消息正文。连接串由 ConnectionStrings:MongoDb 提供,
// 库名从连接串路径解析(与原 MongoUrl 逻辑一致)。
// 用静态 DB.InitAsync 一次性初始化,之后全局通过静态 DB/实体实例方法访问,无需 DI 注入。
var mongoConn = config.GetConnectionString("MongoDb")!;
var mongoDbName = MongoUrl.Create(mongoConn).DatabaseName!;
DB.InitAsync(mongoDbName, MongoClientSettings.FromConnectionString(mongoConn)).GetAwaiter().GetResult();
services.AddImRedis(config);

View File

@ -1,20 +1,18 @@
using IM.Domain.Enums;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Entities;
namespace IM.Infrastructure.Mongo;
/// <summary>
/// 消息 MongoDB 文档模型。IM 消息量级大、写频繁,用 Mongo 承载消息正文与元数据,支撑历史消息检索。
/// 继承 <see cref="Entity"/>:主键 <c>ID</c>(string/ObjectId)由基类提供并自动生成,
/// 替代原 <c>[BsonId] Id</c>。<c>[BsonRepresentation]</c> 特性全部保留,存量数据格式兼容。
/// </summary>
public class MessageDocument
public class MessageDocument : Entity
{
// ObjectId 作为主键,BsonRepresentation 使其在 C# 端表现为 string,便于游标透传
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = default!;
// Guid 以 String 形式存入 Mongo,保证与 PostgreSQL 中的 ConversationId/SenderId 一致可比
[BsonRepresentation(BsonType.String)]
public Guid ConversationId { get; set; }

View File

@ -1,44 +0,0 @@
using MongoDB.Driver;
namespace IM.Infrastructure.Mongo;
/// <summary>
/// MongoDB 上下文,负责承载消息集合(高频追加写场景)。
/// 与 PostgreSQL 的 <c>ImDbContext</c> 分工:消息正文落 MongoDB,会话/群组元数据落 PostgreSQL。
/// </summary>
public class MongoDbContext
{
private readonly IMongoDatabase _database;
public MongoDbContext(IMongoClient mongoClient, string databaseName)
{
_database = mongoClient.GetDatabase(databaseName);
}
/// <summary>消息集合。每条文档对应一条聊天消息,主键为 MongoDB ObjectId。</summary>
public IMongoCollection<MessageDocument> Messages =>
_database.GetCollection<MessageDocument>("messages");
/// <summary>
/// 创建查询所需索引。Admin Host 启动时调用一次(幂等)。
/// </summary>
/// <remarks>
/// 两个复合索引分别支撑两种查询路径:
/// <list type="bullet">
/// <item>{ConversationId ASC, CreatedAt DESC} —— 按时间倒序拉取会话最新消息(默认进入页面)。</item>
/// <item>{ConversationId ASC, Id DESC} —— 游标分页按 ObjectId 倒序,支撑"上拉加载更早消息"。</item>
/// </list>
/// </remarks>
public async Task CreateIndexesAsync()
{
var indexKeys = Builders<MessageDocument>.IndexKeys
.Ascending(m => m.ConversationId)
.Descending(m => m.CreatedAt);
await Messages.Indexes.CreateOneAsync(new CreateIndexModel<MessageDocument>(indexKeys));
var cursorIndexKeys = Builders<MessageDocument>.IndexKeys
.Ascending(m => m.ConversationId)
.Descending(m => m.Id);
await Messages.Indexes.CreateOneAsync(new CreateIndexModel<MessageDocument>(cursorIndexKeys));
}
}

View File

@ -7,7 +7,7 @@ namespace IM.Infrastructure.Persistence;
/// <summary>
/// IM 服务 PostgreSQL 上下文。管理会话、参与者、群组、群成员四张元数据表。
/// 消息正文不在此处,存于 MongoDB(见 <see cref="Mongo.MongoDbContext"/>)。
/// 消息正文不在此处,存于 MongoDB(见 <see cref="Mongo.MessageDocument"/>,经 MongoDB.Entities 访问)。
/// </summary>
public class ImDbContext : DbContext
{