From c5a3243bd8c377bfbb9cba4d804d074ba2241b2d Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Sun, 14 Dec 2025 16:12:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20tenants=20=E5=88=97=E8=A1=A8=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=90=8D=E7=A7=B0/=E8=81=94=E7=B3=BB=E4=BA=BA/?= =?UTF-8?q?=E7=94=B5=E8=AF=9D/=E8=AE=A4=E8=AF=81=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Handlers/SearchTenantsQueryHandler.cs | 51 +++++--- .../App/Tenants/Queries/SearchTenantsQuery.cs | 47 ++++++- .../Validators/SearchTenantsQueryValidator.cs | 23 ++++ .../Tenants/Repositories/ITenantRepository.cs | 44 +++++++ .../App/Repositories/EfTenantRepository.cs | 116 +++++++++++++++++- 5 files changed, 260 insertions(+), 21 deletions(-) create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Validators/SearchTenantsQueryValidator.cs diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs index 755b911..3344923 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs @@ -15,26 +15,47 @@ public sealed class SearchTenantsQueryHandler(ITenantRepository tenantRepository /// public async Task> Handle(SearchTenantsQuery request, CancellationToken cancellationToken) { - // 1. 查询租户列表 - var tenants = await tenantRepository.SearchAsync(request.Status, request.Keyword, cancellationToken); - var total = tenants.Count; + // 1. 按条件分页查询租户 + var (tenants, total) = await tenantRepository.SearchPagedAsync( + request.Status, + request.VerificationStatus, + request.Name, + request.ContactName, + request.ContactPhone, + request.Keyword, + request.Page, + request.PageSize, + cancellationToken); - // 2. 分页 - var paged = tenants - .Skip((request.Page - 1) * request.PageSize) - .Take(request.PageSize) - .ToList(); - - // 3. 映射 DTO(带订阅与认证) - var result = new List(paged.Count); - foreach (var tenant in paged) + // 2. (空行后) 无数据直接返回 + if (tenants.Count == 0) { - var subscription = await tenantRepository.GetActiveSubscriptionAsync(tenant.Id, cancellationToken); - var verification = await tenantRepository.GetVerificationProfileAsync(tenant.Id, cancellationToken); + return new PagedResult([], request.Page, request.PageSize, total); + } + + // 3. (空行后) 批量查询订阅与实名资料(避免 N+1) + var tenantIds = tenants.Select(x => x.Id).ToArray(); + var subscriptionsTask = tenantRepository.GetSubscriptionsAsync(tenantIds, cancellationToken); + var verificationsTask = tenantRepository.GetVerificationProfilesAsync(tenantIds, cancellationToken); + await Task.WhenAll(subscriptionsTask, verificationsTask); + + // 4. (空行后) 构建订阅与实名资料映射 + var subscriptionByTenantId = (await subscriptionsTask) + .GroupBy(x => x.TenantId) + .ToDictionary(x => x.Key, x => x.FirstOrDefault()); + var verificationByTenantId = (await verificationsTask) + .ToDictionary(x => x.TenantId); + + // 5. (空行后) 映射 DTO(带订阅与认证) + var result = new List(tenants.Count); + foreach (var tenant in tenants) + { + subscriptionByTenantId.TryGetValue(tenant.Id, out var subscription); + verificationByTenantId.TryGetValue(tenant.Id, out var verification); result.Add(TenantMapping.ToDto(tenant, subscription, verification)); } - // 4. 返回分页结果 + // 6. (空行后) 返回分页结果 return new PagedResult(result, request.Page, request.PageSize, total); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs index f3834d1..05f34d9 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs @@ -8,8 +8,45 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries; /// /// 租户分页查询。 /// -public sealed record SearchTenantsQuery( - TenantStatus? Status, - string? Keyword, - int Page = 1, - int PageSize = 20) : IRequest>; +public sealed record SearchTenantsQuery : IRequest> +{ + /// + /// 租户状态(精确匹配)。 + /// + public TenantStatus? Status { get; init; } + + /// + /// 实名认证状态(精确匹配)。 + /// + public TenantVerificationStatus? VerificationStatus { get; init; } + + /// + /// 租户名称(模糊匹配)。 + /// + public string? Name { get; init; } + + /// + /// 联系人姓名(模糊匹配)。 + /// + public string? ContactName { get; init; } + + /// + /// 联系电话(模糊匹配)。 + /// + public string? ContactPhone { get; init; } + + /// + /// 兼容关键词:按“名称/编码/联系人/电话”做模糊匹配。 + /// + public string? Keyword { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页大小。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/SearchTenantsQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/SearchTenantsQueryValidator.cs new file mode 100644 index 0000000..95ee503 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/SearchTenantsQueryValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Tenants.Queries; + +namespace TakeoutSaaS.Application.App.Tenants.Validators; + +/// +/// 租户列表查询验证器。 +/// +public sealed class SearchTenantsQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public SearchTenantsQueryValidator() + { + RuleFor(x => x.Page).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 200); + RuleFor(x => x.Keyword).MaximumLength(128); + RuleFor(x => x.Name).MaximumLength(128); + RuleFor(x => x.ContactName).MaximumLength(64); + RuleFor(x => x.ContactPhone).MaximumLength(32); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs index a6ebbc0..cb03323 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs @@ -28,6 +28,30 @@ public interface ITenantRepository string? keyword, CancellationToken cancellationToken = default); + /// + /// 分页查询租户(支持多条件过滤)。 + /// + /// 租户状态,为空不按状态过滤。 + /// 实名认证状态,为空不按认证状态过滤。 + /// 租户名称,为空不按名称过滤。 + /// 联系人姓名,为空不按联系人过滤。 + /// 联系电话,为空不按电话过滤。 + /// 兼容关键词:名称/编码/联系人/电话,为空不按关键字过滤。 + /// 页码(从 1 开始)。 + /// 每页大小。 + /// 取消标记。 + /// 分页数据与总数。 + Task<(IReadOnlyList Items, int Total)> SearchPagedAsync( + TenantStatus? status, + TenantVerificationStatus? verificationStatus, + string? name, + string? contactName, + string? contactPhone, + string? keyword, + int page, + int pageSize, + CancellationToken cancellationToken = default); + /// /// 新增租户。 /// @@ -76,6 +100,16 @@ public interface ITenantRepository /// 实名资料实体,未提交返回 null。 Task GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取实名资料。 + /// + /// 租户 ID 列表。 + /// 取消标记。 + /// 实名资料列表(未提交的不返回)。 + Task> GetVerificationProfilesAsync( + IReadOnlyCollection tenantIds, + CancellationToken cancellationToken = default); + /// /// 新增或更新实名资料。 /// @@ -92,6 +126,16 @@ public interface ITenantRepository /// 当前有效订阅,若无则 null。 Task GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取当前订阅。 + /// + /// 租户 ID 列表。 + /// 取消标记。 + /// 订阅列表(可能包含同一租户的多条订阅记录)。 + Task> GetSubscriptionsAsync( + IReadOnlyCollection tenantIds, + CancellationToken cancellationToken = default); + /// /// 依据订阅 ID 查询。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs index 61e7ff0..ae90dd4 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs @@ -41,7 +41,8 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep query = query.Where(x => EF.Functions.ILike(x.Name, $"%{keyword}%") || EF.Functions.ILike(x.Code, $"%{keyword}%") || - EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%")); + EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%") || + EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{keyword}%")); } // 4. 排序返回 @@ -50,6 +51,82 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep .ToListAsync(cancellationToken); } + /// + public async Task<(IReadOnlyList Items, int Total)> SearchPagedAsync( + TenantStatus? status, + TenantVerificationStatus? verificationStatus, + string? name, + string? contactName, + string? contactPhone, + string? keyword, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + var query = context.Tenants.AsNoTracking(); + + // 1. 按租户状态过滤 + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + // 2. 按实名认证状态过滤(未提交视为 Draft) + if (verificationStatus.HasValue) + { + query = from tenant in query + join profile in context.TenantVerificationProfiles.AsNoTracking() + on tenant.Id equals profile.TenantId into profiles + from profile in profiles.DefaultIfEmpty() + where (profile == null ? TenantVerificationStatus.Draft : profile.Status) == verificationStatus.Value + select tenant; + } + + // 3. 按名称/联系人/电话过滤(模糊匹配) + if (!string.IsNullOrWhiteSpace(name)) + { + var normalizedName = name.Trim(); + query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalizedName}%")); + } + + // 4. (空行后) 按联系人过滤(模糊匹配) + if (!string.IsNullOrWhiteSpace(contactName)) + { + var normalizedContactName = contactName.Trim(); + query = query.Where(x => EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedContactName}%")); + } + + // 5. (空行后) 按联系电话过滤(模糊匹配) + if (!string.IsNullOrWhiteSpace(contactPhone)) + { + var normalizedContactPhone = contactPhone.Trim(); + query = query.Where(x => EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedContactPhone}%")); + } + + // 6. (空行后) 兼容关键字查询:名称/编码/联系人/电话 + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalizedKeyword = keyword.Trim(); + query = query.Where(x => + EF.Functions.ILike(x.Name, $"%{normalizedKeyword}%") || + EF.Functions.ILike(x.Code, $"%{normalizedKeyword}%") || + EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedKeyword}%") || + EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedKeyword}%")); + } + + // 7. (空行后) 先统计总数,再按创建时间倒序分页 + var total = await query.CountAsync(cancellationToken); + + // 8. (空行后) 查询当前页数据 + var items = await query + .OrderByDescending(x => x.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, total); + } + /// public Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default) { @@ -97,6 +174,24 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep .FirstOrDefaultAsync(x => x.TenantId == tenantId, cancellationToken); } + /// + public async Task> GetVerificationProfilesAsync( + IReadOnlyCollection tenantIds, + CancellationToken cancellationToken = default) + { + // 1. tenantIds 为空直接返回 + if (tenantIds.Count == 0) + { + return Array.Empty(); + } + + // 2. 批量查询实名资料 + return await context.TenantVerificationProfiles + .AsNoTracking() + .Where(x => tenantIds.Contains(x.TenantId)) + .ToListAsync(cancellationToken); + } + /// public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default) { @@ -126,6 +221,25 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep .FirstOrDefaultAsync(cancellationToken); } + /// + public async Task> GetSubscriptionsAsync( + IReadOnlyCollection tenantIds, + CancellationToken cancellationToken = default) + { + // 1. tenantIds 为空直接返回 + if (tenantIds.Count == 0) + { + return Array.Empty(); + } + + // 2. 批量查询订阅数据 + return await context.TenantSubscriptions + .AsNoTracking() + .Where(x => tenantIds.Contains(x.TenantId)) + .OrderByDescending(x => x.EffectiveTo) + .ToListAsync(cancellationToken); + } + /// public Task FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default) {