feat: tenants 列表支持名称/联系人/电话/认证状态过滤
This commit is contained in:
@@ -15,26 +15,47 @@ public sealed class SearchTenantsQueryHandler(ITenantRepository tenantRepository
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<TenantDto>> 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<TenantDto>(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<TenantDto>([], 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<TenantDto>(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<TenantDto>(result, request.Page, request.PageSize, total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,45 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
/// <summary>
|
||||
/// 租户分页查询。
|
||||
/// </summary>
|
||||
public sealed record SearchTenantsQuery(
|
||||
TenantStatus? Status,
|
||||
string? Keyword,
|
||||
int Page = 1,
|
||||
int PageSize = 20) : IRequest<PagedResult<TenantDto>>;
|
||||
public sealed record SearchTenantsQuery : IRequest<PagedResult<TenantDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户状态(精确匹配)。
|
||||
/// </summary>
|
||||
public TenantStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 实名认证状态(精确匹配)。
|
||||
/// </summary>
|
||||
public TenantVerificationStatus? VerificationStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称(模糊匹配)。
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系人姓名(模糊匹配)。
|
||||
/// </summary>
|
||||
public string? ContactName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话(模糊匹配)。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 兼容关键词:按“名称/编码/联系人/电话”做模糊匹配。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页大小。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 租户列表查询验证器。
|
||||
/// </summary>
|
||||
public sealed class SearchTenantsQueryValidator : AbstractValidator<SearchTenantsQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,30 @@ public interface ITenantRepository
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询租户(支持多条件过滤)。
|
||||
/// </summary>
|
||||
/// <param name="status">租户状态,为空不按状态过滤。</param>
|
||||
/// <param name="verificationStatus">实名认证状态,为空不按认证状态过滤。</param>
|
||||
/// <param name="name">租户名称,为空不按名称过滤。</param>
|
||||
/// <param name="contactName">联系人姓名,为空不按联系人过滤。</param>
|
||||
/// <param name="contactPhone">联系电话,为空不按电话过滤。</param>
|
||||
/// <param name="keyword">兼容关键词:名称/编码/联系人/电话,为空不按关键字过滤。</param>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页大小。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页数据与总数。</returns>
|
||||
Task<(IReadOnlyList<Tenant> Items, int Total)> SearchPagedAsync(
|
||||
TenantStatus? status,
|
||||
TenantVerificationStatus? verificationStatus,
|
||||
string? name,
|
||||
string? contactName,
|
||||
string? contactPhone,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增租户。
|
||||
/// </summary>
|
||||
@@ -76,6 +100,16 @@ public interface ITenantRepository
|
||||
/// <returns>实名资料实体,未提交返回 null。</returns>
|
||||
Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取实名资料。
|
||||
/// </summary>
|
||||
/// <param name="tenantIds">租户 ID 列表。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>实名资料列表(未提交的不返回)。</returns>
|
||||
Task<IReadOnlyList<TenantVerificationProfile>> GetVerificationProfilesAsync(
|
||||
IReadOnlyCollection<long> tenantIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新实名资料。
|
||||
/// </summary>
|
||||
@@ -92,6 +126,16 @@ public interface ITenantRepository
|
||||
/// <returns>当前有效订阅,若无则 null。</returns>
|
||||
Task<TenantSubscription?> GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取当前订阅。
|
||||
/// </summary>
|
||||
/// <param name="tenantIds">租户 ID 列表。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>订阅列表(可能包含同一租户的多条订阅记录)。</returns>
|
||||
Task<IReadOnlyList<TenantSubscription>> GetSubscriptionsAsync(
|
||||
IReadOnlyCollection<long> tenantIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 依据订阅 ID 查询。
|
||||
/// </summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<Tenant> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantVerificationProfile>> GetVerificationProfilesAsync(
|
||||
IReadOnlyCollection<long> tenantIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. tenantIds 为空直接返回
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantVerificationProfile>();
|
||||
}
|
||||
|
||||
// 2. 批量查询实名资料
|
||||
return await context.TenantVerificationProfiles
|
||||
.AsNoTracking()
|
||||
.Where(x => tenantIds.Contains(x.TenantId))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -126,6 +221,25 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> GetSubscriptionsAsync(
|
||||
IReadOnlyCollection<long> tenantIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. tenantIds 为空直接返回
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantSubscription>();
|
||||
}
|
||||
|
||||
// 2. 批量查询订阅数据
|
||||
return await context.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.Where(x => tenantIds.Contains(x.TenantId))
|
||||
.OrderByDescending(x => x.EffectiveTo)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantSubscription?> FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user