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)
{