refactor: query platform users through mediator

This commit is contained in:
xiangning 2026-07-24 12:10:21 +08:00
parent ce3f29e9cd
commit a5a4446e6c
25 changed files with 273 additions and 167 deletions

View File

@ -227,11 +227,11 @@ git commit -m "refactor: stream rag answers through cqrs"
- Replace: `tests/BuildFlow.IM.Integration.Tests/InMemoryUserInfoService.cs` with `InMemoryUserSummaryQueryHandler.cs`
- Modify: `tests/BuildFlow.IM.Integration.Tests/ImServerFixture.cs`
- [ ] **Step 1: Write Platform query tests and update IM tests to expect mediator sends**
- [x] **Step 1: Write Platform query tests and update IM tests to expect mediator sends**
The Platform query returns a `Dictionary<Guid, UserSummaryDto>` for distinct requested IDs and performs one database query. IM tests must configure `IMediator.Send(new GetUserSummariesQuery(...), ct)` and verify that empty input avoids a send where practical.
The Platform query returns an `IReadOnlyDictionary<Guid, UserSummaryDto>` for distinct requested IDs and performs one database query. IM tests must configure `IMediator.Send(new GetUserSummariesQuery(...), ct)` and verify that empty input avoids a send where practical.
- [ ] **Step 2: Run focused tests to verify RED**
- [x] **Step 2: Run focused tests to verify RED**
```bash
dotnet test tests/BuildFlow.Platform.Tests/BuildFlow.Platform.Tests.csproj --no-restore --filter FullyQualifiedName~GetUserSummaries
@ -240,17 +240,17 @@ dotnet test tests/BuildFlow.IM.Tests/BuildFlow.IM.Tests.csproj --no-restore --fi
Expected: FAIL until the target-owned query and IM handler changes exist.
- [ ] **Step 3: Implement the direct CQRS calls**
- [x] **Step 3: Implement the direct CQRS calls**
Add a Platform Application project reference to IM Application. Each IM handler injects `IMediator`, sends one `GetUserSummariesQuery` for its deduplicated IDs, and reads results by `Guid` key. Do not catch all exceptions and do not introduce a replacement service/interface bridge.
The integration fake implements `IRequestHandler<GetUserSummariesQuery, Dictionary<Guid, UserSummaryDto>>`; `ImServerFixture` registers both the IM Application assembly and the test assembly with MediatR.
The integration fake implements `IRequestHandler<GetUserSummariesQuery, IReadOnlyDictionary<Guid, UserSummaryDto>>`; `ImServerFixture` registers both the IM Application assembly and the test assembly with MediatR.
- [ ] **Step 4: Remove bridge interface, Host implementation, and registration**
- [x] **Step 4: Remove bridge interface, Host implementation, and registration**
Delete `IUserInfoService` and `InProcessUserInfoService`; remove the Host `AddScoped<IUserInfoService, InProcessUserInfoService>()` registration and stale using directives/comments.
- [ ] **Step 5: Run IM unit and SignalR integration tests**
- [x] **Step 5: Run IM unit and SignalR integration tests**
```bash
dotnet test tests/BuildFlow.IM.Tests/BuildFlow.IM.Tests.csproj --no-restore
@ -259,7 +259,7 @@ dotnet test tests/BuildFlow.IM.Integration.Tests/BuildFlow.IM.Integration.Tests.
Expected: PASS with the same private/group conversation display names and notification behavior.
- [ ] **Step 6: Commit**
- [x] **Step 6: Commit**
```bash
git add src/modules/platform/Application/Users src/modules/im src/BuildFlow.Host tests/BuildFlow.IM.Tests tests/BuildFlow.IM.Integration.Tests

View File

@ -91,7 +91,6 @@ public class BuildFlowHostModule : AbpModule
// 当前用户上下文:各模块的 CurrentUserContext 实现
services.AddHttpContextAccessor();
services.AddScoped<BuildFlow.Shared.Domain.Common.ICurrentUserContext, CurrentUserContext>();
services.AddScoped<IUserInfoService, InProcessUserInfoService>();
// 串联:IM @AI → RAG 问答(跨模块集成,RAG 实现 IAiBotHandler 供 IM 调用)
services

View File

@ -1,41 +0,0 @@
using MediatR;
using BuildFlow.Platform.Application.Users.Queries;
using IM.Domain.Common;
namespace BuildFlow.Host.Services.Integration;
/// <summary>
/// 进程内用户信息查询实现,替代 IM 原有的 gRPC 版 <c>AuthGrpcClientService</c>。
/// 通过 MediatR 查询调用 rag 模块的 <see cref="GetUserNamesQuery"/> 处理器,
/// 而非直接注入 <c>PlatformDbContext</c>——这样 im 模块对 rag 的依赖从「直接引用 DbContext」
/// 降级为「发 MediatR 消息」,实现模块间解耦。im 只依赖消息契约,不感知 rag 的数据访问实现。
///
/// 容错策略与 gRPC 版一致:查询失败返回空字典,避免 IM 核心链路因数据库抖动而整体不可用。
/// 注册为 <c>IUserInfoService</c> 的覆盖实现(后注册者胜出),让 BuildFlow Host 内的 IM 直接查询用户信息。
/// </summary>
public class InProcessUserInfoService(IMediator mediator) : IUserInfoService
{
public async Task<Dictionary<string, (string Username, string? Avatar)>> GetUsersAsync(
IEnumerable<Guid> userIds, CancellationToken ct = default)
{
var idList = userIds.ToList();
if (idList.Count == 0)
return [];
try
{
// 通过 MediatR 查询调用 rag 模块的 GetUserNamesQueryHandler(它内部查 PlatformDbContext.Users)
var names = await mediator.Send(new GetUserNamesQuery(idList), ct);
// rag 的 User 无 Avatar 字段(gRPC 服务端也返回 Avatar=""),故头像恒为 null
return names.ToDictionary(
kv => kv.Key.ToString(),
kv => (kv.Value, (string?)null));
}
catch
{
// 数据库不可用时降级返回空,保证消息发送主链路不被阻断(与 gRPC 版容错一致)
return [];
}
}
}

View File

@ -1,6 +1,6 @@
using BuildFlow.Platform.Application.Users.Queries;
using IM.Application.Chat.Commands;
using IM.Application.Chat.DTOs;
using IM.Domain.Common;
using IM.Domain.Entities.Conversation;
using IM.Domain.Entities.ConversationParticipant;
using IM.Domain.Enums;
@ -14,7 +14,7 @@ namespace IM.Application.Chat.Handlers;
/// </summary>
public class CreatePrivateConversationCommandHandler(
ImDbContext db,
IUserInfoService userInfoService)
IMediator mediator)
: IRequestHandler<CreatePrivateConversationCommand, ConversationDto>
{
public async Task<ConversationDto> Handle(
@ -39,8 +39,8 @@ public class CreatePrivateConversationCommandHandler(
})
.FirstOrDefaultAsync(ct);
var users = await userInfoService.GetUsersAsync([request.TargetUserId], ct);
var targetUser = users.GetValueOrDefault(request.TargetUserId.ToString());
var users = await mediator.Send(new GetUserSummariesQuery([request.TargetUserId]), ct);
var targetUser = users.GetValueOrDefault(request.TargetUserId);
if (existing is not null)
{
@ -52,8 +52,8 @@ public class CreatePrivateConversationCommandHandler(
existing.MyUnread,
null, null, null,
request.TargetUserId,
targetUser.Username,
targetUser.Avatar);
targetUser?.Username,
targetUser?.Avatar);
}
// 创建新私聊会话
@ -88,7 +88,7 @@ public class CreatePrivateConversationCommandHandler(
null, null, 0,
null, null, null,
request.TargetUserId,
targetUser.Username,
targetUser.Avatar);
targetUser?.Username,
targetUser?.Avatar);
}
}

View File

@ -1,3 +1,5 @@
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Queries;
using IM.Application.Chat.DTOs;
using IM.Application.Chat.Queries;
using IM.Domain.Common;
@ -11,7 +13,7 @@ namespace IM.Application.Chat.Handlers;
/// </summary>
public class GetConversationsQueryHandler(
ImDbContext db,
IUserInfoService userInfoService,
IMediator mediator,
IUnreadCounter unreadCounter)
: IRequestHandler<GetConversationsQuery, List<ConversationDto>>
{
@ -31,7 +33,7 @@ public class GetConversationsQueryHandler(
.ThenByDescending(p => p.ConversationEntity.LastMessageAt)
.ToListAsync(ct);
// 私聊会话需解析"对方"用户以展示头像/昵称;批量收集去重后一次性查 gRPC,避免逐会话往返
// 私聊会话需解析"对方"用户以展示头像/昵称;批量收集去重后一次性查询 Platform,避免逐会话往返。
var otherUserIds = participants
.Where(p => p.ConversationEntity.Type == Domain.Enums.ConversationTypeEnum.Private)
.Select(p => p.ConversationEntity.Participants
@ -42,8 +44,8 @@ public class GetConversationsQueryHandler(
.ToList();
var users = otherUserIds.Count > 0
? await userInfoService.GetUsersAsync(otherUserIds, ct)
: new Dictionary<string, (string Username, string? Avatar)>();
? await mediator.Send(new GetUserSummariesQuery(otherUserIds), ct)
: new Dictionary<Guid, UserSummaryDto>();
var redisUnread = await unreadCounter.GetByUserAsync(
request.UserId,
@ -63,9 +65,9 @@ public class GetConversationsQueryHandler(
if (otherParticipant is not null)
{
otherUserId = otherParticipant.UserId;
var userInfo = users.GetValueOrDefault(otherParticipant.UserId.ToString());
otherUserName = userInfo.Username;
otherUserAvatar = userInfo.Avatar;
var userInfo = users.GetValueOrDefault(otherParticipant.UserId);
otherUserName = userInfo?.Username;
otherUserAvatar = userInfo?.Avatar;
}
}

View File

@ -1,7 +1,8 @@
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Queries;
using BuildFlow.Shared.Domain.Exceptions;
using IM.Application.Chat.DTOs;
using IM.Application.Chat.Queries;
using IM.Domain.Common;
using IM.Infrastructure.Persistence;
using MediatR;
@ -10,7 +11,7 @@ namespace IM.Application.Chat.Handlers;
/// <summary>
/// 群成员列表查询处理器。校验请求者群成员身份后返回成员。
/// </summary>
public class GetGroupMembersQueryHandler(ImDbContext db, IUserInfoService userInfoService) : IRequestHandler<GetGroupMembersQuery, List<GroupMemberDto>>
public class GetGroupMembersQueryHandler(ImDbContext db, IMediator mediator) : IRequestHandler<GetGroupMembersQuery, List<GroupMemberDto>>
{
public async Task<List<GroupMemberDto>> Handle(GetGroupMembersQuery request, CancellationToken ct)
{
@ -25,14 +26,16 @@ public class GetGroupMembersQueryHandler(ImDbContext db, IUserInfoService userIn
.Where(m => m.GroupId == request.GroupId)
.ToListAsync(ct);
// 批量反查用户名/头像,避免逐成员调用 gRPC
var userIds = members.Select(m => m.UserId).ToList();
var users = await userInfoService.GetUsersAsync(userIds, ct);
// 批量反查用户名/头像,避免逐成员跨模块查询。
var userIds = members.Select(m => m.UserId).Distinct().ToList();
var users = userIds.Count > 0
? await mediator.Send(new GetUserSummariesQuery(userIds), ct)
: new Dictionary<Guid, UserSummaryDto>();
return members.Select(m =>
{
var userInfo = users.GetValueOrDefault(m.UserId.ToString());
return new GroupMemberDto(m.UserId, userInfo.Username ?? string.Empty, userInfo.Avatar, m.Role, m.JoinedAt);
var userInfo = users.GetValueOrDefault(m.UserId);
return new GroupMemberDto(m.UserId, userInfo?.Username ?? string.Empty, userInfo?.Avatar, m.Role, m.JoinedAt);
}).ToList();
}
}

View File

@ -1,7 +1,8 @@
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Queries;
using BuildFlow.Shared.Domain.Exceptions;
using IM.Application.Chat.DTOs;
using IM.Application.Chat.Queries;
using IM.Domain.Common;
using IM.Infrastructure.Mongo;
using IM.Infrastructure.Persistence;
using MediatR;
@ -14,7 +15,7 @@ namespace IM.Application.Chat.Handlers;
/// </summary>
public class GetMessageHistoryQueryHandler(
ImDbContext db,
IUserInfoService userInfoService) : IRequestHandler<GetMessageHistoryQuery, MessageHistoryDto>
IMediator mediator) : IRequestHandler<GetMessageHistoryQuery, MessageHistoryDto>
{
public async Task<MessageHistoryDto> Handle(GetMessageHistoryQuery request, CancellationToken ct)
{
@ -44,7 +45,7 @@ public class GetMessageHistoryQueryHandler(
if (hasMore)
messages.RemoveAt(messages.Count - 1);
// 头像懒补全:仅对消息文档中头像字段为空的发送者批量反查 gRPC,减少调用次数
// 头像懒补全:仅对消息文档中头像字段为空的发送者批量查询 Platform,减少调用次数。
var senderIds = messages
.Where(m => string.IsNullOrEmpty(m.SenderAvatar))
.Select(m => m.SenderId)
@ -52,15 +53,15 @@ public class GetMessageHistoryQueryHandler(
.ToList();
var users = senderIds.Count > 0
? await userInfoService.GetUsersAsync(senderIds, ct)
: new Dictionary<string, (string Username, string? Avatar)>();
? await mediator.Send(new GetUserSummariesQuery(senderIds), ct)
: new Dictionary<Guid, UserSummaryDto>();
var dtos = messages.Select(m =>
{
var avatar = m.SenderAvatar;
if (string.IsNullOrEmpty(avatar))
{
avatar = users.GetValueOrDefault(m.SenderId.ToString()).Avatar;
avatar = users.GetValueOrDefault(m.SenderId)?.Avatar;
}
return new MessageDto(

View File

@ -1,7 +1,8 @@
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Queries;
using BuildFlow.Shared.Domain.Exceptions;
using IM.Application.Chat.DTOs;
using IM.Application.Chat.Queries;
using IM.Domain.Common;
using IM.Infrastructure.Mongo;
using IM.Infrastructure.Persistence;
using MediatR;
@ -14,7 +15,7 @@ namespace IM.Application.Chat.Handlers;
/// </summary>
public class GetMessagesAroundQueryHandler(
ImDbContext db,
IUserInfoService userInfoService) : IRequestHandler<GetMessagesAroundQuery, MessageHistoryDto>
IMediator mediator) : IRequestHandler<GetMessagesAroundQuery, MessageHistoryDto>
{
public async Task<MessageHistoryDto> Handle(GetMessagesAroundQuery request, CancellationToken ct)
{
@ -98,15 +99,15 @@ public class GetMessagesAroundQueryHandler(
.ToList();
var users = senderIds.Count > 0
? await userInfoService.GetUsersAsync(senderIds, ct)
: new Dictionary<string, (string Username, string? Avatar)>();
? await mediator.Send(new GetUserSummariesQuery(senderIds), ct)
: new Dictionary<Guid, UserSummaryDto>();
return messages.Select(m =>
{
var avatar = m.SenderAvatar;
if (string.IsNullOrEmpty(avatar))
{
avatar = users.GetValueOrDefault(m.SenderId.ToString()).Avatar;
avatar = users.GetValueOrDefault(m.SenderId)?.Avatar;
}
return new MessageDto(

View File

@ -1,8 +1,9 @@
using System.Text.RegularExpressions;
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Queries;
using BuildFlow.Shared.Domain.Exceptions;
using IM.Application.Chat.DTOs;
using IM.Application.Chat.Queries;
using IM.Domain.Common;
using IM.Infrastructure.Mongo;
using IM.Infrastructure.Persistence;
using MediatR;
@ -16,7 +17,7 @@ namespace IM.Application.Chat.Handlers;
/// </summary>
public class SearchMessagesQueryHandler(
ImDbContext db,
IUserInfoService userInfoService) : IRequestHandler<SearchMessagesQuery, MessageHistoryDto>
IMediator mediator) : IRequestHandler<SearchMessagesQuery, MessageHistoryDto>
{
public async Task<MessageHistoryDto> Handle(SearchMessagesQuery request, CancellationToken ct)
{
@ -61,15 +62,15 @@ public class SearchMessagesQueryHandler(
.ToList();
var users = senderIds.Count > 0
? await userInfoService.GetUsersAsync(senderIds, ct)
: new Dictionary<string, (string Username, string? Avatar)>();
? await mediator.Send(new GetUserSummariesQuery(senderIds), ct)
: new Dictionary<Guid, UserSummaryDto>();
return messages.Select(m =>
{
var avatar = m.SenderAvatar;
if (string.IsNullOrEmpty(avatar))
{
avatar = users.GetValueOrDefault(m.SenderId.ToString()).Avatar;
avatar = users.GetValueOrDefault(m.SenderId)?.Avatar;
}
return new MessageDto(

View File

@ -1,3 +1,4 @@
using BuildFlow.Platform.Application.Users.Queries;
using BuildFlow.Shared.Domain.Exceptions;
using BuildFlow.Shared.Integration;
using IM.Application.Chat.Commands;
@ -17,7 +18,7 @@ namespace IM.Application.Chat.Handlers;
/// </summary>
public class SendMessageCommandHandler(
ImDbContext db,
IUserInfoService userInfoService,
IMediator mediator,
IUnreadCounter unreadCounter,
IOnlinePresenceService onlinePresence,
IImNotificationPublisher notificationPublisher,
@ -40,9 +41,9 @@ public class SendMessageCommandHandler(
if (!isParticipant)
throw new BusinessException(ErrorCode.Business, "您不是该会话的参与者");
// 通过 gRPC 向 rag-backend 反查发送者头像,与消息一并冗余落库,避免历史消息渲染时再查
var users = await userInfoService.GetUsersAsync([request.SenderId], ct);
var senderAvatar = users.GetValueOrDefault(request.SenderId.ToString()).Avatar;
// 批量查询 Platform 用户摘要,并把发送者头像冗余落库,避免历史消息渲染时重复查询。
var users = await mediator.Send(new GetUserSummariesQuery([request.SenderId]), ct);
var senderAvatar = users.GetValueOrDefault(request.SenderId)?.Avatar;
var message = new MessageDocument
{

View File

@ -13,5 +13,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Infrastructure\IM.Infrastructure.csproj" />
<ProjectReference Include="..\..\platform\Application\Platform.Application.csproj" />
</ItemGroup>
</Project>

View File

@ -1,9 +0,0 @@
namespace IM.Domain.Common;
/// <summary>
/// 用户信息查询抽象。供 IM 模块批量拉取用户展示名/头像,避免直接依赖外部账号服务。
/// </summary>
public interface IUserInfoService
{
Task<Dictionary<string, (string Username, string? Avatar)>> GetUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default);
}

View File

@ -0,0 +1,9 @@
namespace BuildFlow.Platform.Application.Users.DTOs;
/// <summary>
/// 用户摘要 DTO。供其他模块批量读取用户展示名和头像引用。
/// </summary>
public sealed record UserSummaryDto(
Guid Id,
string Username,
string? Avatar);

View File

@ -0,0 +1,31 @@
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Queries;
using BuildFlow.Platform.Infrastructure.Persistence;
using MediatR;
namespace BuildFlow.Platform.Application.Users.Handlers;
/// <summary>
/// 批量用户摘要查询处理器。单次数据库查询返回去重后的现存用户。
/// </summary>
public sealed class GetUserSummariesQueryHandler(PlatformDbContext db)
: IRequestHandler<GetUserSummariesQuery, IReadOnlyDictionary<Guid, UserSummaryDto>>
{
public async Task<IReadOnlyDictionary<Guid, UserSummaryDto>> Handle(
GetUserSummariesQuery request,
CancellationToken ct)
{
var userIds = request.UserIds.Distinct().ToArray();
if (userIds.Length == 0)
{
return new Dictionary<Guid, UserSummaryDto>();
}
return await db.Users
.Where(user => userIds.Contains(user.Id))
.ToDictionaryAsync(
user => user.Id,
user => new UserSummaryDto(user.Id, user.Username, user.Avatar),
ct);
}
}

View File

@ -0,0 +1,10 @@
using BuildFlow.Platform.Application.Users.DTOs;
using MediatR;
namespace BuildFlow.Platform.Application.Users.Queries;
/// <summary>
/// 批量用户摘要查询。按用户 ID 返回展示名和头像引用。
/// </summary>
public sealed record GetUserSummariesQuery(IReadOnlyCollection<Guid> UserIds)
: IRequest<IReadOnlyDictionary<Guid, UserSummaryDto>>;

View File

@ -44,8 +44,8 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
public async Task Non_participant_cannot_join_conversation_group()
{
// Arrange:Alice 与 Bob 的私聊,Outsider 不参与
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var outsider = await ConnectAsync(ImServerFixture.OutSiderId, "Outsider");
@ -89,7 +89,7 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
public async Task Message_history_cursor_pagination_returns_correct_page_and_hasmore()
{
// Arrange:直接落多条消息到 MongoDB(绕过 Hub,聚焦分页逻辑)
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var messageIds = await SeedMessagesAsync(convId, count: 5);
@ -118,8 +118,8 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
public async Task Search_messages_finds_text_sender_and_file_name_with_reply_metadata()
{
// Arrange:同一会话内准备正文、发送者、文件名三类命中;另一个会话放同关键字验证会话隔离。
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "BuildFlow Bob");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "BuildFlow Bob");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var otherConvId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.CarolId);
@ -170,7 +170,7 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
public async Task Get_messages_around_returns_ordered_context_window()
{
// Arrange:写 5 条连续历史,定位第 3 条,要求返回前后各 1 条并按时间正序。
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var messageIds = await SeedMessagesAsync(convId, count: 5);
@ -193,7 +193,7 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
public async Task Get_messages_around_includes_referenced_message_outside_context_window()
{
// Arrange:目标消息引用了更早的消息,但该引用消息不在前后 1 条上下文窗口内。
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var baseTime = DateTime.UtcNow.AddMinutes(-4);

View File

@ -97,8 +97,8 @@ public class ChatHubLifecycleTests : IAsyncLifetime
public async Task Connection_auto_subscribes_user_to_all_existing_conversations()
{
// Arrange:先播种一个会话,随后让参与者连接——验证"连接即订阅"
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var bob = await ConnectAsync(ImServerFixture.BobId, "Bob");

View File

@ -45,8 +45,8 @@ public class ChatHubMessagingTests : IAsyncLifetime
public async Task SendMessage_broadcasts_to_both_participants_and_persists_to_mongo()
{
// Arrange:私聊会话 Alice ↔ Bob,两者均在连接中
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
@ -94,8 +94,8 @@ public class ChatHubMessagingTests : IAsyncLifetime
public async Task SendMessage_file_message_preserves_metadata_reply_and_updates_conversation_summary()
{
// Arrange:前端文件发送链路会先完成分片上传,再通过 ChatHub 发送文件名、大小和预览 URL。
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(
_fixture,
ImServerFixture.AliceId,
@ -167,7 +167,7 @@ public class ChatHubMessagingTests : IAsyncLifetime
public async Task SendMessage_increments_redis_unread_for_other_participant_only()
{
// Arrange:Alice、Bob、Carol 三人群聊。发消息时只给除发送者外的两人累加未读
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
var (convId, _) = await ImTestSeeder.SeedGroupConversationAsync(
_fixture, "三人测试群", ImServerFixture.AliceId,
(ImServerFixture.AliceId, GroupMemberRoleEnum.Owner),
@ -197,7 +197,7 @@ public class ChatHubMessagingTests : IAsyncLifetime
public async Task SendMessage_triggers_offline_notification_for_offline_recipients()
{
// Arrange:Bob 离线(不连接)、Carol 在线(连接)。离线触达仅应发给 Bob
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
var (convId, _) = await ImTestSeeder.SeedGroupConversationAsync(
_fixture, "离线通知群", ImServerFixture.AliceId,
(ImServerFixture.AliceId, GroupMemberRoleEnum.Owner),
@ -231,8 +231,8 @@ public class ChatHubMessagingTests : IAsyncLifetime
public async Task Typing_broadcasts_OnUserTyping_to_participants()
{
// Arrange
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");

View File

@ -45,8 +45,8 @@ public class ChatHubReadAndRecallTests : IAsyncLifetime
public async Task MarkAsRead_resets_unread_in_both_pg_and_redis_and_broadcasts()
{
// Arrange:私聊会话,Alice 先发消息使 Bob 产生未读
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
@ -94,7 +94,7 @@ public class ChatHubReadAndRecallTests : IAsyncLifetime
public async Task RecallMessage_marks_mongo_recalled_and_updates_conversation_summary()
{
// Arrange:私聊会话,Alice 发一条消息(成为最后一条),随后本人撤回
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");

View File

@ -11,8 +11,11 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using FluentValidation;
using MediatR;
using MongoDB.Driver;
using MongoDB.Entities;
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Queries;
using BuildFlow.Shared.Domain.Common;
using BuildFlow.Shared.Infrastructure;
using IM.Application.Chat.Commands;
@ -25,7 +28,7 @@ namespace BuildFlow.IM.Integration.Tests;
/// <summary>
/// 集成测试夹具:在 TestServer 上装配真实 SignalR ChatHub + 真实 PostgreSQL / MongoDB / Redis,
/// 仅替换两个外部 gRPC 依赖(用户信息、通知发布)为进程内 fake,使测试聚焦于"SignalR + DB"主链路。
/// 仅替换用户摘要查询和通知发布为进程内 fake,使测试聚焦于"SignalR + DB"主链路。
/// </summary>
/// <remarks>
/// 设计取舍:
@ -70,8 +73,8 @@ public sealed class ImServerFixture : IAsyncLifetime
/// <summary>外部通知发布的 fake:记录最后一次调用参数,便于断言离线触达。</summary>
public FakeImNotificationPublisher NotificationPublisher { get; } = new();
/// <summary>外部用户信息查询的 fake:返回预设的用户名/头像。</summary>
public InMemoryUserInfoService UserInfoService { get; } = new();
/// <summary>Platform 用户摘要查询 fake:返回预设的用户名和头像。</summary>
public InMemoryUserSummaryQueryHandler UserSummaryHandler { get; } = new();
public async Task InitializeAsync()
{
@ -104,13 +107,17 @@ public sealed class ImServerFixture : IAsyncLifetime
services.AddSingleton<IOnlinePresenceService, RedisOnlinePresenceService>();
services.AddSingleton<IUnreadCounter, RedisUnreadCounter>();
// —— 外部 gRPC 依赖替换为进程内 fake ——
services.AddSingleton<IUserInfoService>(UserInfoService);
// —— 外部通知依赖替换为进程内 fake ——
services.AddSingleton<IImNotificationPublisher>(NotificationPublisher);
// —— MediatR + 校验管线(与生产 Application 模块一致) ——
var assembly = typeof(SendMessageCommand).Assembly;
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddMediatR(cfg => cfg
.RegisterServicesFromAssembly(assembly)
.RegisterServicesFromAssembly(typeof(InMemoryUserSummaryQueryHandler).Assembly));
services.RemoveAll<IRequestHandler<GetUserSummariesQuery, IReadOnlyDictionary<Guid, UserSummaryDto>>>();
services.AddSingleton<IRequestHandler<GetUserSummariesQuery, IReadOnlyDictionary<Guid, UserSummaryDto>>>(
UserSummaryHandler);
services.AddValidatorsFromAssembly(typeof(SendMessageCommand).Assembly);
services.AddSignalR(options =>

View File

@ -1,27 +0,0 @@
using IM.Domain.Common;
namespace BuildFlow.IM.Integration.Tests;
/// <summary>
/// 进程内 <see cref="IUserInfoService"/> 替身:避免真实 gRPC 调用到未运行的 SSO 服务。
/// 测试前调用 <see cref="SetUser"/> 预置用户展示名/头像,Handler 调用时按 ID 返回。
/// </summary>
public sealed class InMemoryUserInfoService : IUserInfoService
{
private readonly Dictionary<Guid, (string Username, string? Avatar)> _users = new();
public void SetUser(Guid userId, string username, string? avatar = null) =>
_users[userId] = (username, avatar);
public Task<Dictionary<string, (string Username, string? Avatar)>> GetUsersAsync(
IEnumerable<Guid> userIds, CancellationToken ct = default)
{
var result = new Dictionary<string, (string Username, string? Avatar)>();
foreach (var id in userIds)
{
if (_users.TryGetValue(id, out var info))
result[id.ToString()] = info;
}
return Task.FromResult(result);
}
}

View File

@ -0,0 +1,32 @@
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Queries;
using MediatR;
namespace BuildFlow.IM.Integration.Tests;
/// <summary>
/// 进程内用户摘要查询处理器。测试前可预置用户展示名和头像,由 MediatR 按用户 ID 批量返回。
/// </summary>
public sealed class InMemoryUserSummaryQueryHandler
: IRequestHandler<GetUserSummariesQuery, IReadOnlyDictionary<Guid, UserSummaryDto>>
{
private readonly Dictionary<Guid, UserSummaryDto> _users = new();
/// <summary>
/// 预置一个用户摘要。
/// </summary>
public void SetUser(Guid userId, string username, string? avatar = null) =>
_users[userId] = new UserSummaryDto(userId, username, avatar);
public Task<IReadOnlyDictionary<Guid, UserSummaryDto>> Handle(
GetUserSummariesQuery request,
CancellationToken ct)
{
IReadOnlyDictionary<Guid, UserSummaryDto> result = request.UserIds
.Distinct()
.Where(_users.ContainsKey)
.ToDictionary(userId => userId, userId => _users[userId]);
return Task.FromResult(result);
}
}

View File

@ -1,8 +1,11 @@
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Queries;
using IM.Application.Chat.Handlers;
using IM.Application.Chat.Queries;
using IM.Domain.Common;
using IM.Domain.Enums;
using IM.Infrastructure.Persistence;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Moq;
@ -30,14 +33,14 @@ public class GetConversationsQueryHandlerTests
(OtherUserId, 0)));
await db.SaveChangesAsync();
var userInfoService = new Mock<IUserInfoService>();
userInfoService
.Setup(s => s.GetUsersAsync(
It.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(new[] { OtherUserId })),
var mediator = new Mock<IMediator>();
mediator
.Setup(m => m.Send(
It.Is<GetUserSummariesQuery>(q => q.UserIds.SequenceEqual(new[] { OtherUserId })),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, (string Username, string? Avatar)>
.ReturnsAsync(new Dictionary<Guid, UserSummaryDto>
{
[OtherUserId.ToString()] = ("Bob", null)
[OtherUserId] = new(OtherUserId, "Bob", null)
});
var unreadCounter = new Mock<IUnreadCounter>();
@ -48,12 +51,16 @@ public class GetConversationsQueryHandlerTests
It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<Guid, int> { [ConversationId] = 2 });
var handler = new GetConversationsQueryHandler(db, userInfoService.Object, unreadCounter.Object);
var handler = new GetConversationsQueryHandler(db, mediator.Object, unreadCounter.Object);
var result = await handler.Handle(new GetConversationsQuery(UserId), CancellationToken.None);
var conversation = Assert.Single(result);
Assert.Equal(2, conversation.UnreadCount);
Assert.Equal("Bob", conversation.OtherUserName);
mediator.Verify(m => m.Send(
It.Is<GetUserSummariesQuery>(q => q.UserIds.SequenceEqual(new[] { OtherUserId })),
It.IsAny<CancellationToken>()), Times.Once);
}
private static ImDbContext BuildInMemoryDb()

View File

@ -1,8 +1,11 @@
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Queries;
using IM.Application.Chat.Commands;
using IM.Application.Chat.Handlers;
using IM.Domain.Common;
using IM.Domain.Enums;
using IM.Infrastructure.Persistence;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
@ -103,6 +106,34 @@ public class SendMessageCommandHandlerTests
Assert.NotNull(conv.LastMessageAt);
}
[Fact]
public async Task Queries_sender_summary_once_and_uses_returned_avatar()
{
await using var db = BuildInMemoryDb();
db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationTypeEnum.Private,
(SenderId, 0), (RecipientA, 0)));
await db.SaveChangesAsync();
var (handler, _, mediator, _, _) = BuildHandler(db);
mediator
.Setup(m => m.Send(
It.Is<GetUserSummariesQuery>(q => q.UserIds.SequenceEqual(new[] { SenderId })),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<Guid, UserSummaryDto>
{
[SenderId] = new(SenderId, "alice", "avatars/alice.png")
});
var result = await handler.Handle(new SendMessageCommand(
ConversationId, SenderId, "alice",
MessageContentTypeEnum.Text, "hello", null, null), CancellationToken.None);
Assert.Equal("avatars/alice.png", result.SenderAvatar);
mediator.Verify(m => m.Send(
It.Is<GetUserSummariesQuery>(q => q.UserIds.SequenceEqual(new[] { SenderId })),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Publishes_notification_to_offline_recipients_only()
{
@ -162,13 +193,13 @@ public class SendMessageCommandHandlerTests
/// 构造 Handler。Mongo 消息正文落真实库(静态 DB.Default 已在静态构造函数初始化),
/// 此处聚焦未读数语义。默认所有接收者视为离线(驱动通知链路),个别测试可覆盖。
/// </summary>
private static (SendMessageCommandHandler Handler, Mock<IUnreadCounter> UnreadCounter, Mock<IUserInfoService> UserInfo, Mock<IOnlinePresenceService> OnlinePresence, Mock<IImNotificationPublisher> Notification) BuildHandler(ImDbContext db)
private static (SendMessageCommandHandler Handler, Mock<IUnreadCounter> UnreadCounter, Mock<IMediator> Mediator, Mock<IOnlinePresenceService> OnlinePresence, Mock<IImNotificationPublisher> Notification) BuildHandler(ImDbContext db)
{
var unreadCounter = new Mock<IUnreadCounter>();
var userInfo = new Mock<IUserInfoService>();
userInfo.Setup(u => u.GetUsersAsync(It.IsAny<IEnumerable<Guid>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, (string, string?)>());
var mediator = new Mock<IMediator>();
mediator.Setup(m => m.Send(It.IsAny<GetUserSummariesQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<Guid, UserSummaryDto>());
// 默认全部离线(返回传入集合本身),驱动通知触发
var onlinePresence = new Mock<IOnlinePresenceService>();
@ -178,8 +209,8 @@ public class SendMessageCommandHandlerTests
var notification = new Mock<IImNotificationPublisher>();
var handler = new SendMessageCommandHandler(
db, userInfo.Object, unreadCounter.Object, onlinePresence.Object, notification.Object);
return (handler, unreadCounter, userInfo, onlinePresence, notification);
db, mediator.Object, unreadCounter.Object, onlinePresence.Object, notification.Object);
return (handler, unreadCounter, mediator, onlinePresence, notification);
}
private static ImDbContext BuildInMemoryDb()

View File

@ -0,0 +1,47 @@
using BuildFlow.Platform.Application.Users.DTOs;
using BuildFlow.Platform.Application.Users.Handlers;
using BuildFlow.Platform.Application.Users.Queries;
using Xunit;
namespace BuildFlow.Platform.Tests.Users;
/// <summary>
/// 批量用户摘要查询测试。覆盖去重、缺失用户过滤、头像返回和空输入短路。
/// </summary>
public class GetUserSummariesQueryHandlerTests
{
[Fact]
public async Task Handle_ShouldReturnDistinctExistingUsersByGuid()
{
await using var db = PlatformTestDb.CreateDbContext();
var aliceId = Guid.NewGuid();
var bobId = Guid.NewGuid();
var missingId = Guid.NewGuid();
var alice = await PlatformTestDb.SeedUserAsync(db, aliceId, "alice");
var bob = await PlatformTestDb.SeedUserAsync(db, bobId, "bob");
alice.Avatar = "avatars/alice.png";
bob.Avatar = null;
await db.SaveChangesAsync();
var handler = new GetUserSummariesQueryHandler(db);
var result = await handler.Handle(
new GetUserSummariesQuery([aliceId, bobId, aliceId, missingId]),
CancellationToken.None);
Assert.Equal(2, result.Count);
Assert.Equal(new UserSummaryDto(aliceId, "alice", "avatars/alice.png"), result[aliceId]);
Assert.Equal(new UserSummaryDto(bobId, "bob", null), result[bobId]);
Assert.False(result.ContainsKey(missingId));
}
[Fact]
public async Task Handle_EmptyIds_ShouldReturnEmptyResult()
{
await using var db = PlatformTestDb.CreateDbContext();
var handler = new GetUserSummariesQueryHandler(db);
var result = await handler.Handle(new GetUserSummariesQuery([]), CancellationToken.None);
Assert.Empty(result);
}
}