diff --git a/docs/superpowers/plans/2026-07-24-buildflow-architecture-and-sop.md b/docs/superpowers/plans/2026-07-24-buildflow-architecture-and-sop.md index 4ad322a..7160e03 100644 --- a/docs/superpowers/plans/2026-07-24-buildflow-architecture-and-sop.md +++ b/docs/superpowers/plans/2026-07-24-buildflow-architecture-and-sop.md @@ -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` 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` 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>`; `ImServerFixture` registers both the IM Application assembly and the test assembly with MediatR. +The integration fake implements `IRequestHandler>`; `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()` 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 diff --git a/src/BuildFlow.Host/BuildFlowHostModule.cs b/src/BuildFlow.Host/BuildFlowHostModule.cs index 29348d7..4253485 100644 --- a/src/BuildFlow.Host/BuildFlowHostModule.cs +++ b/src/BuildFlow.Host/BuildFlowHostModule.cs @@ -91,7 +91,6 @@ public class BuildFlowHostModule : AbpModule // 当前用户上下文:各模块的 CurrentUserContext 实现 services.AddHttpContextAccessor(); services.AddScoped(); - services.AddScoped(); // 串联:IM @AI → RAG 问答(跨模块集成,RAG 实现 IAiBotHandler 供 IM 调用) services diff --git a/src/BuildFlow.Host/Services/Integration/InProcessUserInfoService.cs b/src/BuildFlow.Host/Services/Integration/InProcessUserInfoService.cs deleted file mode 100644 index 7ee3c37..0000000 --- a/src/BuildFlow.Host/Services/Integration/InProcessUserInfoService.cs +++ /dev/null @@ -1,41 +0,0 @@ -using MediatR; -using BuildFlow.Platform.Application.Users.Queries; -using IM.Domain.Common; - -namespace BuildFlow.Host.Services.Integration; - -/// -/// 进程内用户信息查询实现,替代 IM 原有的 gRPC 版 AuthGrpcClientService。 -/// 通过 MediatR 查询调用 rag 模块的 处理器, -/// 而非直接注入 PlatformDbContext——这样 im 模块对 rag 的依赖从「直接引用 DbContext」 -/// 降级为「发 MediatR 消息」,实现模块间解耦。im 只依赖消息契约,不感知 rag 的数据访问实现。 -/// -/// 容错策略与 gRPC 版一致:查询失败返回空字典,避免 IM 核心链路因数据库抖动而整体不可用。 -/// 注册为 IUserInfoService 的覆盖实现(后注册者胜出),让 BuildFlow Host 内的 IM 直接查询用户信息。 -/// -public class InProcessUserInfoService(IMediator mediator) : IUserInfoService -{ - public async Task> GetUsersAsync( - IEnumerable 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 []; - } - } -} diff --git a/src/modules/im/Application/Chat/Handlers/CreatePrivateConversationCommandHandler.cs b/src/modules/im/Application/Chat/Handlers/CreatePrivateConversationCommandHandler.cs index 96e3d63..d7a1ae5 100644 --- a/src/modules/im/Application/Chat/Handlers/CreatePrivateConversationCommandHandler.cs +++ b/src/modules/im/Application/Chat/Handlers/CreatePrivateConversationCommandHandler.cs @@ -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; /// public class CreatePrivateConversationCommandHandler( ImDbContext db, - IUserInfoService userInfoService) + IMediator mediator) : IRequestHandler { public async Task 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); } } diff --git a/src/modules/im/Application/Chat/Handlers/GetConversationsQueryHandler.cs b/src/modules/im/Application/Chat/Handlers/GetConversationsQueryHandler.cs index 93ab7a0..ee8d047 100644 --- a/src/modules/im/Application/Chat/Handlers/GetConversationsQueryHandler.cs +++ b/src/modules/im/Application/Chat/Handlers/GetConversationsQueryHandler.cs @@ -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; /// public class GetConversationsQueryHandler( ImDbContext db, - IUserInfoService userInfoService, + IMediator mediator, IUnreadCounter unreadCounter) : IRequestHandler> { @@ -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(); + ? await mediator.Send(new GetUserSummariesQuery(otherUserIds), ct) + : new Dictionary(); 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; } } diff --git a/src/modules/im/Application/Chat/Handlers/GetGroupMembersQueryHandler.cs b/src/modules/im/Application/Chat/Handlers/GetGroupMembersQueryHandler.cs index 7522977..7bd2863 100644 --- a/src/modules/im/Application/Chat/Handlers/GetGroupMembersQueryHandler.cs +++ b/src/modules/im/Application/Chat/Handlers/GetGroupMembersQueryHandler.cs @@ -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; /// /// 群成员列表查询处理器。校验请求者群成员身份后返回成员。 /// -public class GetGroupMembersQueryHandler(ImDbContext db, IUserInfoService userInfoService) : IRequestHandler> +public class GetGroupMembersQueryHandler(ImDbContext db, IMediator mediator) : IRequestHandler> { public async Task> 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(); 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(); } } diff --git a/src/modules/im/Application/Chat/Handlers/GetMessageHistoryQueryHandler.cs b/src/modules/im/Application/Chat/Handlers/GetMessageHistoryQueryHandler.cs index ca2646d..5ad3219 100644 --- a/src/modules/im/Application/Chat/Handlers/GetMessageHistoryQueryHandler.cs +++ b/src/modules/im/Application/Chat/Handlers/GetMessageHistoryQueryHandler.cs @@ -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; /// public class GetMessageHistoryQueryHandler( ImDbContext db, - IUserInfoService userInfoService) : IRequestHandler + IMediator mediator) : IRequestHandler { public async Task 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(); + ? await mediator.Send(new GetUserSummariesQuery(senderIds), ct) + : new Dictionary(); 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( diff --git a/src/modules/im/Application/Chat/Handlers/GetMessagesAroundQueryHandler.cs b/src/modules/im/Application/Chat/Handlers/GetMessagesAroundQueryHandler.cs index f9c3431..f1a3c54 100644 --- a/src/modules/im/Application/Chat/Handlers/GetMessagesAroundQueryHandler.cs +++ b/src/modules/im/Application/Chat/Handlers/GetMessagesAroundQueryHandler.cs @@ -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; /// public class GetMessagesAroundQueryHandler( ImDbContext db, - IUserInfoService userInfoService) : IRequestHandler + IMediator mediator) : IRequestHandler { public async Task 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(); + ? await mediator.Send(new GetUserSummariesQuery(senderIds), ct) + : new Dictionary(); 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( diff --git a/src/modules/im/Application/Chat/Handlers/SearchMessagesQueryHandler.cs b/src/modules/im/Application/Chat/Handlers/SearchMessagesQueryHandler.cs index 9725561..9d35e2a 100644 --- a/src/modules/im/Application/Chat/Handlers/SearchMessagesQueryHandler.cs +++ b/src/modules/im/Application/Chat/Handlers/SearchMessagesQueryHandler.cs @@ -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; /// public class SearchMessagesQueryHandler( ImDbContext db, - IUserInfoService userInfoService) : IRequestHandler + IMediator mediator) : IRequestHandler { public async Task 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(); + ? await mediator.Send(new GetUserSummariesQuery(senderIds), ct) + : new Dictionary(); 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( diff --git a/src/modules/im/Application/Chat/Handlers/SendMessageCommandHandler.cs b/src/modules/im/Application/Chat/Handlers/SendMessageCommandHandler.cs index fae661f..a9e965f 100644 --- a/src/modules/im/Application/Chat/Handlers/SendMessageCommandHandler.cs +++ b/src/modules/im/Application/Chat/Handlers/SendMessageCommandHandler.cs @@ -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; /// 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 { diff --git a/src/modules/im/Application/IM.Application.csproj b/src/modules/im/Application/IM.Application.csproj index f34c735..72752b1 100644 --- a/src/modules/im/Application/IM.Application.csproj +++ b/src/modules/im/Application/IM.Application.csproj @@ -13,5 +13,6 @@ + diff --git a/src/modules/im/Domain/Common/IUserInfoService.cs b/src/modules/im/Domain/Common/IUserInfoService.cs deleted file mode 100644 index 888affb..0000000 --- a/src/modules/im/Domain/Common/IUserInfoService.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace IM.Domain.Common; - -/// -/// 用户信息查询抽象。供 IM 模块批量拉取用户展示名/头像,避免直接依赖外部账号服务。 -/// -public interface IUserInfoService -{ - Task> GetUsersAsync(IEnumerable userIds, CancellationToken ct = default); -} diff --git a/src/modules/platform/Application/Users/DTOs/UserSummaryDto.cs b/src/modules/platform/Application/Users/DTOs/UserSummaryDto.cs new file mode 100644 index 0000000..5272a0c --- /dev/null +++ b/src/modules/platform/Application/Users/DTOs/UserSummaryDto.cs @@ -0,0 +1,9 @@ +namespace BuildFlow.Platform.Application.Users.DTOs; + +/// +/// 用户摘要 DTO。供其他模块批量读取用户展示名和头像引用。 +/// +public sealed record UserSummaryDto( + Guid Id, + string Username, + string? Avatar); diff --git a/src/modules/platform/Application/Users/Handlers/GetUserSummariesQueryHandler.cs b/src/modules/platform/Application/Users/Handlers/GetUserSummariesQueryHandler.cs new file mode 100644 index 0000000..ec93270 --- /dev/null +++ b/src/modules/platform/Application/Users/Handlers/GetUserSummariesQueryHandler.cs @@ -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; + +/// +/// 批量用户摘要查询处理器。单次数据库查询返回去重后的现存用户。 +/// +public sealed class GetUserSummariesQueryHandler(PlatformDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + GetUserSummariesQuery request, + CancellationToken ct) + { + var userIds = request.UserIds.Distinct().ToArray(); + if (userIds.Length == 0) + { + return new Dictionary(); + } + + return await db.Users + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync( + user => user.Id, + user => new UserSummaryDto(user.Id, user.Username, user.Avatar), + ct); + } +} diff --git a/src/modules/platform/Application/Users/Queries/GetUserSummariesQuery.cs b/src/modules/platform/Application/Users/Queries/GetUserSummariesQuery.cs new file mode 100644 index 0000000..20932c2 --- /dev/null +++ b/src/modules/platform/Application/Users/Queries/GetUserSummariesQuery.cs @@ -0,0 +1,10 @@ +using BuildFlow.Platform.Application.Users.DTOs; +using MediatR; + +namespace BuildFlow.Platform.Application.Users.Queries; + +/// +/// 批量用户摘要查询。按用户 ID 返回展示名和头像引用。 +/// +public sealed record GetUserSummariesQuery(IReadOnlyCollection UserIds) + : IRequest>; diff --git a/tests/BuildFlow.IM.Integration.Tests/ChatHubAuthAndHistoryTests.cs b/tests/BuildFlow.IM.Integration.Tests/ChatHubAuthAndHistoryTests.cs index 944bb26..9e3435e 100644 --- a/tests/BuildFlow.IM.Integration.Tests/ChatHubAuthAndHistoryTests.cs +++ b/tests/BuildFlow.IM.Integration.Tests/ChatHubAuthAndHistoryTests.cs @@ -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); diff --git a/tests/BuildFlow.IM.Integration.Tests/ChatHubLifecycleTests.cs b/tests/BuildFlow.IM.Integration.Tests/ChatHubLifecycleTests.cs index 7ceac50..95977c1 100644 --- a/tests/BuildFlow.IM.Integration.Tests/ChatHubLifecycleTests.cs +++ b/tests/BuildFlow.IM.Integration.Tests/ChatHubLifecycleTests.cs @@ -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"); diff --git a/tests/BuildFlow.IM.Integration.Tests/ChatHubMessagingTests.cs b/tests/BuildFlow.IM.Integration.Tests/ChatHubMessagingTests.cs index e7dac45..8420b34 100644 --- a/tests/BuildFlow.IM.Integration.Tests/ChatHubMessagingTests.cs +++ b/tests/BuildFlow.IM.Integration.Tests/ChatHubMessagingTests.cs @@ -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"); diff --git a/tests/BuildFlow.IM.Integration.Tests/ChatHubReadAndRecallTests.cs b/tests/BuildFlow.IM.Integration.Tests/ChatHubReadAndRecallTests.cs index 3490ed5..472ca48 100644 --- a/tests/BuildFlow.IM.Integration.Tests/ChatHubReadAndRecallTests.cs +++ b/tests/BuildFlow.IM.Integration.Tests/ChatHubReadAndRecallTests.cs @@ -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"); diff --git a/tests/BuildFlow.IM.Integration.Tests/ImServerFixture.cs b/tests/BuildFlow.IM.Integration.Tests/ImServerFixture.cs index 747915d..1767734 100644 --- a/tests/BuildFlow.IM.Integration.Tests/ImServerFixture.cs +++ b/tests/BuildFlow.IM.Integration.Tests/ImServerFixture.cs @@ -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; /// /// 集成测试夹具:在 TestServer 上装配真实 SignalR ChatHub + 真实 PostgreSQL / MongoDB / Redis, -/// 仅替换两个外部 gRPC 依赖(用户信息、通知发布)为进程内 fake,使测试聚焦于"SignalR + DB"主链路。 +/// 仅替换用户摘要查询和通知发布为进程内 fake,使测试聚焦于"SignalR + DB"主链路。 /// /// /// 设计取舍: @@ -70,8 +73,8 @@ public sealed class ImServerFixture : IAsyncLifetime /// 外部通知发布的 fake:记录最后一次调用参数,便于断言离线触达。 public FakeImNotificationPublisher NotificationPublisher { get; } = new(); - /// 外部用户信息查询的 fake:返回预设的用户名/头像。 - public InMemoryUserInfoService UserInfoService { get; } = new(); + /// Platform 用户摘要查询 fake:返回预设的用户名和头像。 + public InMemoryUserSummaryQueryHandler UserSummaryHandler { get; } = new(); public async Task InitializeAsync() { @@ -104,13 +107,17 @@ public sealed class ImServerFixture : IAsyncLifetime services.AddSingleton(); services.AddSingleton(); - // —— 外部 gRPC 依赖替换为进程内 fake —— - services.AddSingleton(UserInfoService); + // —— 外部通知依赖替换为进程内 fake —— services.AddSingleton(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>>(); + services.AddSingleton>>( + UserSummaryHandler); services.AddValidatorsFromAssembly(typeof(SendMessageCommand).Assembly); services.AddSignalR(options => diff --git a/tests/BuildFlow.IM.Integration.Tests/InMemoryUserInfoService.cs b/tests/BuildFlow.IM.Integration.Tests/InMemoryUserInfoService.cs deleted file mode 100644 index 5e6e652..0000000 --- a/tests/BuildFlow.IM.Integration.Tests/InMemoryUserInfoService.cs +++ /dev/null @@ -1,27 +0,0 @@ -using IM.Domain.Common; - -namespace BuildFlow.IM.Integration.Tests; - -/// -/// 进程内 替身:避免真实 gRPC 调用到未运行的 SSO 服务。 -/// 测试前调用 预置用户展示名/头像,Handler 调用时按 ID 返回。 -/// -public sealed class InMemoryUserInfoService : IUserInfoService -{ - private readonly Dictionary _users = new(); - - public void SetUser(Guid userId, string username, string? avatar = null) => - _users[userId] = (username, avatar); - - public Task> GetUsersAsync( - IEnumerable userIds, CancellationToken ct = default) - { - var result = new Dictionary(); - foreach (var id in userIds) - { - if (_users.TryGetValue(id, out var info)) - result[id.ToString()] = info; - } - return Task.FromResult(result); - } -} diff --git a/tests/BuildFlow.IM.Integration.Tests/InMemoryUserSummaryQueryHandler.cs b/tests/BuildFlow.IM.Integration.Tests/InMemoryUserSummaryQueryHandler.cs new file mode 100644 index 0000000..93b8de4 --- /dev/null +++ b/tests/BuildFlow.IM.Integration.Tests/InMemoryUserSummaryQueryHandler.cs @@ -0,0 +1,32 @@ +using BuildFlow.Platform.Application.Users.DTOs; +using BuildFlow.Platform.Application.Users.Queries; +using MediatR; + +namespace BuildFlow.IM.Integration.Tests; + +/// +/// 进程内用户摘要查询处理器。测试前可预置用户展示名和头像,由 MediatR 按用户 ID 批量返回。 +/// +public sealed class InMemoryUserSummaryQueryHandler + : IRequestHandler> +{ + private readonly Dictionary _users = new(); + + /// + /// 预置一个用户摘要。 + /// + public void SetUser(Guid userId, string username, string? avatar = null) => + _users[userId] = new UserSummaryDto(userId, username, avatar); + + public Task> Handle( + GetUserSummariesQuery request, + CancellationToken ct) + { + IReadOnlyDictionary result = request.UserIds + .Distinct() + .Where(_users.ContainsKey) + .ToDictionary(userId => userId, userId => _users[userId]); + + return Task.FromResult(result); + } +} diff --git a/tests/BuildFlow.IM.Tests/Chat/GetConversationsQueryHandlerTests.cs b/tests/BuildFlow.IM.Tests/Chat/GetConversationsQueryHandlerTests.cs index 904c2fe..882001c 100644 --- a/tests/BuildFlow.IM.Tests/Chat/GetConversationsQueryHandlerTests.cs +++ b/tests/BuildFlow.IM.Tests/Chat/GetConversationsQueryHandlerTests.cs @@ -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(); - userInfoService - .Setup(s => s.GetUsersAsync( - It.Is>(ids => ids.SequenceEqual(new[] { OtherUserId })), + var mediator = new Mock(); + mediator + .Setup(m => m.Send( + It.Is(q => q.UserIds.SequenceEqual(new[] { OtherUserId })), It.IsAny())) - .ReturnsAsync(new Dictionary + .ReturnsAsync(new Dictionary { - [OtherUserId.ToString()] = ("Bob", null) + [OtherUserId] = new(OtherUserId, "Bob", null) }); var unreadCounter = new Mock(); @@ -48,12 +51,16 @@ public class GetConversationsQueryHandlerTests It.IsAny())) .ReturnsAsync(new Dictionary { [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(q => q.UserIds.SequenceEqual(new[] { OtherUserId })), + It.IsAny()), Times.Once); } private static ImDbContext BuildInMemoryDb() diff --git a/tests/BuildFlow.IM.Tests/Chat/SendMessageCommandHandlerTests.cs b/tests/BuildFlow.IM.Tests/Chat/SendMessageCommandHandlerTests.cs index a426aa4..968fb16 100644 --- a/tests/BuildFlow.IM.Tests/Chat/SendMessageCommandHandlerTests.cs +++ b/tests/BuildFlow.IM.Tests/Chat/SendMessageCommandHandlerTests.cs @@ -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(q => q.UserIds.SequenceEqual(new[] { SenderId })), + It.IsAny())) + .ReturnsAsync(new Dictionary + { + [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(q => q.UserIds.SequenceEqual(new[] { SenderId })), + It.IsAny()), Times.Once); + } + [Fact] public async Task Publishes_notification_to_offline_recipients_only() { @@ -162,13 +193,13 @@ public class SendMessageCommandHandlerTests /// 构造 Handler。Mongo 消息正文落真实库(静态 DB.Default 已在静态构造函数初始化), /// 此处聚焦未读数语义。默认所有接收者视为离线(驱动通知链路),个别测试可覆盖。 /// - private static (SendMessageCommandHandler Handler, Mock UnreadCounter, Mock UserInfo, Mock OnlinePresence, Mock Notification) BuildHandler(ImDbContext db) + private static (SendMessageCommandHandler Handler, Mock UnreadCounter, Mock Mediator, Mock OnlinePresence, Mock Notification) BuildHandler(ImDbContext db) { var unreadCounter = new Mock(); - var userInfo = new Mock(); - userInfo.Setup(u => u.GetUsersAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync(new Dictionary()); + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Dictionary()); // 默认全部离线(返回传入集合本身),驱动通知触发 var onlinePresence = new Mock(); @@ -178,8 +209,8 @@ public class SendMessageCommandHandlerTests var notification = new Mock(); 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() diff --git a/tests/BuildFlow.Platform.Tests/Users/GetUserSummariesQueryHandlerTests.cs b/tests/BuildFlow.Platform.Tests/Users/GetUserSummariesQueryHandlerTests.cs new file mode 100644 index 0000000..dc67f87 --- /dev/null +++ b/tests/BuildFlow.Platform.Tests/Users/GetUserSummariesQueryHandlerTests.cs @@ -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; + +/// +/// 批量用户摘要查询测试。覆盖去重、缺失用户过滤、头像返回和空输入短路。 +/// +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); + } +}