feat: 商品列表查询改为数据库分页过滤
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m53s

This commit is contained in:
2026-02-26 10:50:20 +08:00
parent 8f64eb897b
commit 3f5ca9c3ee
3 changed files with 173 additions and 54 deletions

View File

@@ -23,62 +23,26 @@ public sealed class SearchProductsQueryHandler(
{
var tenantId = _tenantProvider.GetCurrentTenantId();
// 沽清查询需要包含 OffShelf 状态后再做二次过滤
var repositoryStatus = request.IsSoldOut == true ? null : request.Status;
var products = await _productRepository.SearchAsync(
tenantId,
request.StoreId,
request.CategoryId,
repositoryStatus,
cancellationToken);
IEnumerable<Domain.Products.Entities.Product> filtered = products;
if (!string.IsNullOrWhiteSpace(request.Keyword))
// 沽清查询不走 Status 过滤,保持“仅沽清”口径
var status = request.IsSoldOut == true ? null : request.Status;
var filter = new ProductSearchFilter
{
var keyword = request.Keyword.Trim().ToLowerInvariant();
filtered = filtered.Where(item =>
item.Name.ToLower().Contains(keyword) ||
item.SpuCode.ToLower().Contains(keyword));
}
if (request.Kind.HasValue)
{
filtered = filtered.Where(item => item.Kind == request.Kind.Value);
}
if (request.IsSoldOut == true)
{
filtered = filtered.Where(item => item.SoldoutMode.HasValue);
}
else if (request.Status == Domain.Products.Enums.ProductStatus.OffShelf)
{
filtered = filtered.Where(item => !item.SoldoutMode.HasValue);
}
var filteredList = filtered.ToList();
var sorted = ApplySorting(filteredList, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
var items = paged.Select(MapToDto).ToList();
return new PagedResult<ProductDto>(items, request.Page, request.PageSize, filteredList.Count);
}
private static IOrderedEnumerable<Domain.Products.Entities.Product> ApplySorting(
IReadOnlyCollection<Domain.Products.Entities.Product> products,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"name" => sortDescending ? products.OrderByDescending(x => x.Name) : products.OrderBy(x => x.Name),
"price" => sortDescending ? products.OrderByDescending(x => x.Price) : products.OrderBy(x => x.Price),
"status" => sortDescending ? products.OrderByDescending(x => x.Status) : products.OrderBy(x => x.Status),
_ => sortDescending ? products.OrderByDescending(x => x.CreatedAt) : products.OrderBy(x => x.CreatedAt)
TenantId = tenantId,
StoreId = request.StoreId,
CategoryId = request.CategoryId,
Status = status,
Kind = request.Kind,
Keyword = request.Keyword,
IsSoldOut = request.IsSoldOut,
Page = request.Page,
PageSize = request.PageSize,
SortBy = request.SortBy,
SortDescending = request.SortDescending
};
var (products, totalCount) = await _productRepository.SearchPagedAsync(filter, cancellationToken);
var items = products.Select(MapToDto).ToList();
return new PagedResult<ProductDto>(items, request.Page, request.PageSize, totalCount);
}
private static ProductDto MapToDto(Domain.Products.Entities.Product product) => new()

View File

@@ -18,6 +18,13 @@ public interface IProductRepository
/// </summary>
Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null);
/// <summary>
/// 分页查询商品列表。
/// </summary>
Task<(IReadOnlyList<Product> Items, int Total)> SearchPagedAsync(
ProductSearchFilter filter,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取租户下的商品分类。
/// </summary>
@@ -499,3 +506,64 @@ public interface IProductRepository
/// <returns>异步任务。</returns>
Task RemovePricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// 商品分页查询过滤条件。
/// </summary>
public sealed record ProductSearchFilter
{
/// <summary>
/// 租户 ID。
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 门店 ID可选
/// </summary>
public long? StoreId { get; init; }
/// <summary>
/// 分类 ID可选
/// </summary>
public long? CategoryId { get; init; }
/// <summary>
/// 状态过滤。
/// </summary>
public ProductStatus? Status { get; init; }
/// <summary>
/// 商品类型过滤。
/// </summary>
public ProductKind? Kind { get; init; }
/// <summary>
/// 关键字(名称/SPU
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 是否仅沽清商品。
/// </summary>
public bool? IsSoldOut { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段name/price/status/createdAt
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否倒序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -60,6 +60,93 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
return products;
}
/// <inheritdoc />
public async Task<(IReadOnlyList<Product> Items, int Total)> SearchPagedAsync(
ProductSearchFilter filter,
CancellationToken cancellationToken = default)
{
if (filter.TenantId <= 0)
{
throw new InvalidOperationException("TenantId 不能为空且必须大于 0");
}
var page = filter.Page <= 0 ? 1 : filter.Page;
var pageSize = filter.PageSize <= 0 ? 20 : filter.PageSize;
var query = context.Products
.AsNoTracking()
.Where(x => x.TenantId == filter.TenantId);
if (filter.StoreId.HasValue)
{
query = query.Where(x => x.StoreId == filter.StoreId.Value);
}
if (filter.CategoryId.HasValue)
{
query = query.Where(x => x.CategoryId == filter.CategoryId.Value);
}
if (filter.Status.HasValue)
{
query = query.Where(x => x.Status == filter.Status.Value);
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = $"%{filter.Keyword.Trim()}%";
query = query.Where(x =>
EF.Functions.ILike(x.Name, keyword) ||
EF.Functions.ILike(x.SpuCode, keyword));
}
if (filter.Kind.HasValue)
{
query = query.Where(x => x.Kind == filter.Kind.Value);
}
if (filter.IsSoldOut == true)
{
query = query.Where(x => x.SoldoutMode.HasValue);
}
else if (filter.Status == ProductStatus.OffShelf)
{
// 状态为 OffShelf 时,排除“沽清”以保持前台“下架”口径。
query = query.Where(x => !x.SoldoutMode.HasValue);
}
var sorted = ApplySearchSort(query, filter.SortBy, filter.SortDescending);
var total = await sorted.CountAsync(cancellationToken);
var items = await sorted
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
private static IOrderedQueryable<Product> ApplySearchSort(
IQueryable<Product> query,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"name" => sortDescending
? query.OrderByDescending(x => x.Name).ThenByDescending(x => x.Id)
: query.OrderBy(x => x.Name).ThenBy(x => x.Id),
"price" => sortDescending
? query.OrderByDescending(x => x.Price).ThenByDescending(x => x.Id)
: query.OrderBy(x => x.Price).ThenBy(x => x.Id),
"status" => sortDescending
? query.OrderByDescending(x => x.Status).ThenByDescending(x => x.Id)
: query.OrderBy(x => x.Status).ThenBy(x => x.Id),
_ => sortDescending
? query.OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id)
: query.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id)
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductCategory>> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default)
{