diff --git a/Document/11_SystemTodo.md b/Document/11_SystemTodo.md
index 076085d..dac5cff 100644
--- a/Document/11_SystemTodo.md
+++ b/Document/11_SystemTodo.md
@@ -28,6 +28,13 @@
## 4. 安全与合规
- [ ] RBAC 权限、租户隔离、用户/权限洞察 API 完整演示并在 Swagger 中提供示例。
+ - [ ] 现状梳理:租户解析/过滤已具备(TenantResolutionMiddleware、TenantAwareDbContext),JWT 已写入 roles/permissions/tenant_id(JwtTokenService),PermissionAuthorize 已在 Admin API 使用,CurrentUserProfile 含角色/权限/租户;但仅有内嵌 string[] 权限存储,无角色/权限表与洞察查询,Swagger 缺少示例与多租户示例。
+ - [ ] 差距与步骤:
+ - [ ] 增加权限/租户洞察查询(按用户、按租户分页)并确保带 tenant 过滤(TenantAwareDbContext 或 Dapper 参数化)。
+ - [ ] 输出可读的角色/权限列表(基于现有种子/配置的只读查询)。
+ - [ ] 为洞察接口和 /auth/profile 增加 Swagger 示例,包含 tenant_id、roles、permissions,展示 Bearer 示例与租户 Header 示例。
+ - [ ] 若用 Dapper 读侧,SQL 必须参数化并显式过滤 tenant_id。
+ - [ ] 计划顺序:Step A 设计应用层洞察 DTO/Query;Step B Admin API 只读端点(Authorize/PermissionAuthorize);Step C Swagger 示例扩展;Step D 校验租户过滤与忽略路径配置。
- [ ] 登录/刷新流程增加 IP 校验、租户隔离、验证码/频率限制。
- [ ] 登录/权限/敏感操作日志可追溯,提供查询接口或 Kibana Saved Search。
- [ ] Secret Store/KeyVault/KMS 管理敏感配置,禁止密钥写入 Git/数据库明文。
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs
index b406c49..72f23f4 100644
--- a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs
@@ -55,6 +55,29 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
///
/// 获取当前用户信息
///
+ ///
+ /// 示例:
+ ///
+ /// GET /api/admin/v1/auth/profile
+ /// Header: Authorization: Bearer <JWT>
+ /// 响应:
+ /// {
+ /// "success": true,
+ /// "code": 200,
+ /// "message": "操作成功",
+ /// "data": {
+ /// "userId": "900123456789012345",
+ /// "account": "admin@tenant1",
+ /// "displayName": "租户管理员",
+ /// "tenantId": "100000000000000001",
+ /// "merchantId": null,
+ /// "roles": ["TenantAdmin"],
+ /// "permissions": ["identity:permission:read", "merchant:read", "order:read"],
+ /// "avatar": "https://cdn.example.com/avatar.png"
+ /// }
+ /// }
+ ///
+ ///
[HttpGet("profile")]
[PermissionAuthorize("identity:profile:read")]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
@@ -70,4 +93,41 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
var profile = await authService.GetProfileAsync(userId, cancellationToken);
return ApiResponse.Ok(profile);
}
+
+ ///
+ /// 查询指定用户的角色与权限概览(当前租户范围)。
+ ///
+ ///
+ /// 示例:
+ ///
+ /// GET /api/admin/v1/auth/permissions/900123456789012346
+ /// Header: Authorization: Bearer <JWT>
+ /// 响应:
+ /// {
+ /// "success": true,
+ /// "code": 200,
+ /// "data": {
+ /// "userId": "900123456789012346",
+ /// "tenantId": "100000000000000001",
+ /// "merchantId": "200000000000000001",
+ /// "account": "ops.manager",
+ /// "displayName": "运营经理",
+ /// "roles": ["OpsManager", "Reporter"],
+ /// "permissions": ["delivery:read", "order:read", "payment:read"],
+ /// "createdAt": "2025-12-01T08:30:00Z"
+ /// }
+ /// }
+ ///
+ ///
+ [HttpGet("permissions/{userId:long}")]
+ [PermissionAuthorize("identity:permission:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> GetUserPermissions(long userId, CancellationToken cancellationToken)
+ {
+ var result = await authService.GetUserPermissionsAsync(userId, cancellationToken);
+ return result is null
+ ? ApiResponse.Error(ErrorCodes.NotFound, "用户不存在或不属于当前租户")
+ : ApiResponse.Ok(result);
+ }
}
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs
new file mode 100644
index 0000000..328499a
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs
@@ -0,0 +1,71 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.Identity.Abstractions;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Application.Identity.Queries;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+
+namespace TakeoutSaaS.AdminApi.Controllers;
+
+///
+/// 用户权限洞察接口。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/admin/v{version:apiVersion}/users/permissions")]
+public sealed class UserPermissionsController(IAdminAuthService authService) : BaseApiController
+{
+ ///
+ /// 分页查询当前租户用户的角色与权限概览。
+ ///
+ ///
+ /// 示例:
+ ///
+ /// GET /api/admin/v1/users/permissions?keyword=ops&page=1&pageSize=20&sortBy=createdAt&sortDescending=true
+ /// Header: Authorization: Bearer <JWT>
+ /// 响应:
+ /// {
+ /// "success": true,
+ /// "code": 200,
+ /// "data": {
+ /// "items": [
+ /// {
+ /// "userId": "900123456789012346",
+ /// "tenantId": "100000000000000001",
+ /// "merchantId": "200000000000000001",
+ /// "account": "ops.manager",
+ /// "displayName": "运营经理",
+ /// "roles": ["OpsManager", "Reporter"],
+ /// "permissions": ["delivery:read", "order:read", "payment:read"],
+ /// "createdAt": "2025-12-01T08:30:00Z"
+ /// }
+ /// ],
+ /// "page": 1,
+ /// "pageSize": 20,
+ /// "totalCount": 1,
+ /// "totalPages": 1
+ /// }
+ /// }
+ ///
+ ///
+ [HttpGet]
+ [PermissionAuthorize("identity:permission:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> Search(
+ [FromQuery] SearchUserPermissionsQuery query,
+ CancellationToken cancellationToken)
+ {
+ var result = await authService.SearchUserPermissionsAsync(
+ query.Keyword,
+ query.Page,
+ query.PageSize,
+ query.SortBy,
+ query.SortDescending,
+ cancellationToken);
+
+ return ApiResponse>.Ok(result);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs
index 28510e3..d1cd0a7 100644
--- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs
+++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs
@@ -2,6 +2,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.Identity.Abstractions;
@@ -13,4 +14,6 @@ public interface IAdminAuthService
Task LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
Task GetProfileAsync(long userId, CancellationToken cancellationToken = default);
+ Task GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default);
+ Task> SearchUserPermissionsAsync(string? keyword, int page, int pageSize, string? sortBy, bool sortDescending, CancellationToken cancellationToken = default);
}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs
new file mode 100644
index 0000000..f3393ad
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.Identity.Contracts;
+
+///
+/// 用户权限概览 DTO。
+///
+public sealed class UserPermissionDto
+{
+ ///
+ /// 用户 ID(雪花,序列化为字符串)。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long UserId { get; init; }
+
+ ///
+ /// 租户 ID(雪花,序列化为字符串)。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 商户 ID(雪花,序列化为字符串,可空)。
+ ///
+ [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
+ public long? MerchantId { get; init; }
+
+ ///
+ /// 登录账号。
+ ///
+ public string Account { get; init; } = string.Empty;
+
+ ///
+ /// 展示名称。
+ ///
+ public string DisplayName { get; init; } = string.Empty;
+
+ ///
+ /// 角色集合。
+ ///
+ public string[] Roles { get; init; } = Array.Empty();
+
+ ///
+ /// 权限集合。
+ ///
+ public string[] Permissions { get; init; } = Array.Empty();
+
+ ///
+ /// 创建时间(UTC)。
+ ///
+ public DateTime CreatedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs
new file mode 100644
index 0000000..cdc727b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs
@@ -0,0 +1,42 @@
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Application.Identity.Queries;
+using TakeoutSaaS.Domain.Identity.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.Identity.Handlers;
+
+///
+/// 按用户 ID 获取权限概览处理器。
+///
+public sealed class GetUserPermissionsQueryHandler(
+ IIdentityUserRepository identityUserRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository;
+ private readonly ITenantProvider _tenantProvider = tenantProvider;
+
+ ///
+ public async Task Handle(GetUserPermissionsQuery request, CancellationToken cancellationToken)
+ {
+ var tenantId = _tenantProvider.GetCurrentTenantId();
+ var user = await _identityUserRepository.FindByIdAsync(request.UserId, cancellationToken);
+ if (user == null || user.TenantId != tenantId)
+ {
+ return null;
+ }
+
+ return new UserPermissionDto
+ {
+ UserId = user.Id,
+ TenantId = user.TenantId,
+ MerchantId = user.MerchantId,
+ Account = user.Account,
+ DisplayName = user.DisplayName,
+ Roles = user.Roles,
+ Permissions = user.Permissions,
+ CreatedAt = user.CreatedAt
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs
new file mode 100644
index 0000000..adfe198
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs
@@ -0,0 +1,67 @@
+using System.Linq;
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Application.Identity.Queries;
+using TakeoutSaaS.Domain.Identity.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.Identity.Handlers;
+
+///
+/// 租户用户权限分页查询处理器。
+///
+public sealed class SearchUserPermissionsQueryHandler(
+ IIdentityUserRepository identityUserRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler>
+{
+ private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository;
+ private readonly ITenantProvider _tenantProvider = tenantProvider;
+
+ ///
+ public async Task> Handle(SearchUserPermissionsQuery request, CancellationToken cancellationToken)
+ {
+ var tenantId = _tenantProvider.GetCurrentTenantId();
+ var users = await _identityUserRepository.SearchAsync(tenantId, request.Keyword, cancellationToken);
+
+ var sorted = SortUsers(users, request.SortBy, request.SortDescending);
+ var paged = sorted
+ .Skip((request.Page - 1) * request.PageSize)
+ .Take(request.PageSize)
+ .ToList();
+
+ var items = paged.Select(user => new UserPermissionDto
+ {
+ UserId = user.Id,
+ TenantId = user.TenantId,
+ MerchantId = user.MerchantId,
+ Account = user.Account,
+ DisplayName = user.DisplayName,
+ Roles = user.Roles,
+ Permissions = user.Permissions,
+ CreatedAt = user.CreatedAt
+ }).ToList();
+
+ return new PagedResult(items, request.Page, request.PageSize, users.Count);
+ }
+
+ private static IOrderedEnumerable SortUsers(
+ IReadOnlyCollection users,
+ string? sortBy,
+ bool sortDescending)
+ {
+ return sortBy?.ToLowerInvariant() switch
+ {
+ "account" => sortDescending
+ ? users.OrderByDescending(x => x.Account)
+ : users.OrderBy(x => x.Account),
+ "displayname" => sortDescending
+ ? users.OrderByDescending(x => x.DisplayName)
+ : users.OrderBy(x => x.DisplayName),
+ _ => sortDescending
+ ? users.OrderByDescending(x => x.CreatedAt)
+ : users.OrderBy(x => x.CreatedAt)
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/GetUserPermissionsQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetUserPermissionsQuery.cs
new file mode 100644
index 0000000..84b11d2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetUserPermissionsQuery.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+
+namespace TakeoutSaaS.Application.Identity.Queries;
+
+///
+/// 按用户 ID 获取角色/权限概览。
+///
+public sealed class GetUserPermissionsQuery : IRequest
+{
+ ///
+ /// 用户 ID(雪花)。
+ ///
+ public long UserId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchUserPermissionsQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchUserPermissionsQuery.cs
new file mode 100644
index 0000000..1a6d557
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchUserPermissionsQuery.cs
@@ -0,0 +1,36 @@
+using MediatR;
+using TakeoutSaaS.Application.Identity.Contracts;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.Identity.Queries;
+
+///
+/// 按租户分页查询用户的角色/权限概览。
+///
+public sealed class SearchUserPermissionsQuery : IRequest>
+{
+ ///
+ /// 关键字(账号或展示名称)。
+ ///
+ public string? Keyword { get; init; }
+
+ ///
+ /// 页码,从 1 开始。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页条数。
+ ///
+ public int PageSize { get; init; } = 20;
+
+ ///
+ /// 排序字段(account/displayName/createdAt)。
+ ///
+ public string? SortBy { get; init; }
+
+ ///
+ /// 是否倒序。
+ ///
+ public bool SortDescending { get; init; } = true;
+}
diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs
index 42418a4..e7b91fb 100644
--- a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs
+++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs
@@ -5,6 +5,8 @@ using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Services;
@@ -15,8 +17,11 @@ public sealed class AdminAuthService(
IIdentityUserRepository userRepository,
IPasswordHasher passwordHasher,
IJwtTokenService jwtTokenService,
- IRefreshTokenStore refreshTokenStore) : IAdminAuthService
+ IRefreshTokenStore refreshTokenStore,
+ ITenantProvider tenantProvider) : IAdminAuthService
{
+ private readonly ITenantProvider _tenantProvider = tenantProvider;
+
///
/// 管理后台登录:验证账号密码并生成令牌。
///
@@ -85,6 +90,78 @@ public sealed class AdminAuthService(
return BuildProfile(user);
}
+ ///
+ /// 获取指定用户的权限概览(校验当前租户)。
+ ///
+ public async Task GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default)
+ {
+ var tenantId = _tenantProvider.GetCurrentTenantId();
+ var user = await userRepository.FindByIdAsync(userId, cancellationToken);
+ if (user == null || user.TenantId != tenantId)
+ {
+ return null;
+ }
+
+ return new UserPermissionDto
+ {
+ UserId = user.Id,
+ TenantId = user.TenantId,
+ MerchantId = user.MerchantId,
+ Account = user.Account,
+ DisplayName = user.DisplayName,
+ Roles = user.Roles,
+ Permissions = user.Permissions,
+ CreatedAt = user.CreatedAt
+ };
+ }
+
+ ///
+ /// 按租户分页查询用户权限概览。
+ ///
+ public async Task> SearchUserPermissionsAsync(
+ string? keyword,
+ int page,
+ int pageSize,
+ string? sortBy,
+ bool sortDescending,
+ CancellationToken cancellationToken = default)
+ {
+ var tenantId = _tenantProvider.GetCurrentTenantId();
+ var users = await userRepository.SearchAsync(tenantId, keyword, cancellationToken);
+
+ var sorted = sortBy?.ToLowerInvariant() switch
+ {
+ "account" => sortDescending
+ ? users.OrderByDescending(x => x.Account)
+ : users.OrderBy(x => x.Account),
+ "displayname" => sortDescending
+ ? users.OrderByDescending(x => x.DisplayName)
+ : users.OrderBy(x => x.DisplayName),
+ _ => sortDescending
+ ? users.OrderByDescending(x => x.CreatedAt)
+ : users.OrderBy(x => x.CreatedAt)
+ };
+
+ var paged = sorted
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToList();
+
+ var items = paged.Select(user => new UserPermissionDto
+ {
+ UserId = user.Id,
+ TenantId = user.TenantId,
+ MerchantId = user.MerchantId,
+ Account = user.Account,
+ DisplayName = user.DisplayName,
+ Roles = user.Roles,
+ Permissions = user.Permissions,
+ CreatedAt = user.CreatedAt
+ }).ToList();
+
+ return new PagedResult(items, page, pageSize, users.Count);
+ }
+
private static CurrentUserProfile BuildProfile(IdentityUser user)
=> new()
{
diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs
index cc74343..80d0a1e 100644
--- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Domain.Identity.Entities;
@@ -19,4 +20,12 @@ public interface IIdentityUserRepository
/// 根据 ID 获取后台用户。
///
Task FindByIdAsync(long userId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按租户与关键字查询后台用户列表(仅读)。
+ ///
+ /// 租户 ID。
+ /// 可选关键字(账号/名称)。
+ /// 取消标记。
+ Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default);
}
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs
index a355874..e1f657d 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
@@ -18,4 +19,19 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
public Task FindByIdAsync(long userId, CancellationToken cancellationToken = default)
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
+
+ public async Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
+ {
+ var query = dbContext.IdentityUsers
+ .AsNoTracking()
+ .Where(x => x.TenantId == tenantId);
+
+ if (!string.IsNullOrWhiteSpace(keyword))
+ {
+ var normalized = keyword.Trim();
+ query = query.Where(x => x.Account.Contains(normalized) || x.DisplayName.Contains(normalized));
+ }
+
+ return await query.ToListAsync(cancellationToken);
+ }
}