37c7cebf92
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 23m11s
- Modified `GetTopSellingAsync` methods in `ProductService` and `DiscountProductService` to include an optional `inStock` parameter for filtering products based on availability. - Introduced a constant for the top product count in `Index.razor.cs` to improve maintainability and readability. - Updated the product retrieval logic in `Index.razor.cs` to utilize the new in-stock filtering feature. These changes enhance the product listing functionality by allowing users to view only in-stock top-selling products, improving the overall shopping experience.
314 lines
9.5 KiB
C#
314 lines
9.5 KiB
C#
using System;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.Diagnostics.CodeAnalysis;
|
||
using System.Linq;
|
||
using CMSMicroservice.Protobuf.Protos.Products;
|
||
using Google.Protobuf.WellKnownTypes;
|
||
|
||
namespace FrontOffice.Main.Utilities;
|
||
|
||
public record Product(
|
||
long Id,
|
||
string Title,
|
||
string Description,
|
||
string FullInformation,
|
||
string ImageUrl,
|
||
long Price,
|
||
int Discount = 1,
|
||
int Rate = 0,
|
||
int RemainingCount = 0)
|
||
{
|
||
public IReadOnlyList<ProductGalleryImage> Gallery { get; init; }
|
||
= [];
|
||
|
||
public IReadOnlyList<ProductCategoryPathInfo> Categories { get; init; }
|
||
= [];
|
||
}
|
||
|
||
public record ProductListResult(
|
||
List<Product> Products,
|
||
int TotalCount,
|
||
int TotalPages,
|
||
int CurrentPage,
|
||
bool HasNext);
|
||
|
||
public record ProductGalleryImage(
|
||
long ProductGalleryId,
|
||
long ProductImageId,
|
||
string Title,
|
||
string ImageUrl,
|
||
string ThumbnailUrl);
|
||
|
||
public record ProductCategoryNodeInfo(
|
||
long Id,
|
||
string Title,
|
||
long? ParentId);
|
||
|
||
public record ProductCategoryPathInfo(
|
||
long CategoryId,
|
||
string Title,
|
||
IReadOnlyList<ProductCategoryNodeInfo> Nodes)
|
||
{
|
||
public string DisplayLabel => string.Join(" › ", Nodes.Select(node => node.Title));
|
||
public ProductCategoryNodeInfo Leaf => Nodes.Last();
|
||
}
|
||
|
||
public class ProductService
|
||
{
|
||
private readonly ConcurrentDictionary<long, CacheEntry> _cache = new();
|
||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(1);
|
||
private readonly CMSMicroservice.Protobuf.Protos.Products.ProductsContract.ProductsContractClient _client;
|
||
|
||
public ProductService(CMSMicroservice.Protobuf.Protos.Products.ProductsContract.ProductsContractClient client)
|
||
{
|
||
_client = client;
|
||
}
|
||
|
||
public async Task<List<Product>> GetProductsAsync(string? query = null, long? categoryId = null, string? sortBy = null)
|
||
{
|
||
var result = await GetProductsPagedAsync(query: query, categoryId: categoryId, sortBy: sortBy, page: 1, pageSize: 500);
|
||
return result.Products;
|
||
}
|
||
|
||
public Task<ProductListResult> GetTopSellingAsync(int count = 6, bool? inStock = null)
|
||
=> GetProductsPagedAsync(sortBy: "SaleCount desc", page: 1, pageSize: count, inStock: inStock);
|
||
|
||
public async Task<ProductListResult> GetProductsPagedAsync(
|
||
string? query = null, long? categoryId = null, string? sortBy = null,
|
||
int page = 1, int pageSize = 12, bool? inStock = null)
|
||
{
|
||
try
|
||
{
|
||
var request = new GetAllProductsByFilterRequest
|
||
{
|
||
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
|
||
{
|
||
PageNumber = page,
|
||
PageSize = pageSize
|
||
},
|
||
Filter = new GetAllProductsByFilterFilter()
|
||
};
|
||
|
||
// فقط Title — CMS فیلترها را AND میزند؛ ارسال query در همه فیلدها جستجو را میشکند
|
||
if (!string.IsNullOrWhiteSpace(query))
|
||
{
|
||
request.Filter.Title = query.Trim();
|
||
}
|
||
|
||
if (categoryId is { } value)
|
||
{
|
||
request.Filter.CategoryId = value;
|
||
}
|
||
|
||
if (inStock.HasValue)
|
||
{
|
||
request.Filter.InStock = inStock.Value;
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(sortBy))
|
||
{
|
||
request.SortBy = sortBy;
|
||
}
|
||
|
||
var resp = await _client.GetAllProductsByFilterAsync(request);
|
||
var products = MapAndCache(resp.Models);
|
||
var totalCount = (int)(resp.MetaData?.TotalCount ?? 0);
|
||
var totalPages = (int)(resp.MetaData?.TotalPage ?? 0);
|
||
var hasNext = resp.MetaData?.HasNext ?? false;
|
||
|
||
return new ProductListResult(products, totalCount, totalPages, page, hasNext);
|
||
}
|
||
catch
|
||
{
|
||
IEnumerable<Product> list = GetValidCachedProducts();
|
||
if (!string.IsNullOrWhiteSpace(query))
|
||
{
|
||
var q = query.Trim();
|
||
list = list.Where(p =>
|
||
p.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||
p.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
var all = list.OrderBy(p => p.Id).ToList();
|
||
return new ProductListResult(all, all.Count, 1, 1, false);
|
||
}
|
||
}
|
||
|
||
public async Task<Product?> GetByIdAsync(long id)
|
||
{
|
||
if (TryGetCachedProduct(id, out var cached) && HasDetailedData(cached))
|
||
{
|
||
return cached;
|
||
}
|
||
|
||
try
|
||
{
|
||
var resp = await _client.GetProductsAsync(new GetProductsRequest { Id = id });
|
||
if (resp == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return MapAndCache(resp);
|
||
}
|
||
catch
|
||
{
|
||
if (cached is not null)
|
||
{
|
||
return cached;
|
||
}
|
||
|
||
TryGetCachedProduct(id, out var result);
|
||
return result;
|
||
}
|
||
}
|
||
|
||
private List<Product> MapAndCache(
|
||
Google.Protobuf.Collections.RepeatedField<GetAllProductsByFilterResponseModel> models)
|
||
{
|
||
var list = new List<Product>();
|
||
foreach (var m in models)
|
||
{
|
||
var p = new Product(
|
||
Id: m.Id,
|
||
Title: m.Title ?? string.Empty,
|
||
Description: m.Description ?? string.Empty,
|
||
FullInformation: m.FullInformation ?? string.Empty,
|
||
ImageUrl: m.ImagePath,
|
||
Price: m.Price,
|
||
Discount: m.Discount,
|
||
Rate: m.Rate,
|
||
RemainingCount: m.RemainingCount
|
||
);
|
||
|
||
p = PreserveCachedDetails(p);
|
||
CacheProduct(p);
|
||
|
||
list.Add(p);
|
||
}
|
||
|
||
return list;
|
||
}
|
||
|
||
private Product PreserveCachedDetails(Product product)
|
||
{
|
||
if (_cache.TryGetValue(product.Id, out var entry))
|
||
{
|
||
var cached = entry.Product;
|
||
var hasGallery = cached.Gallery.Count > 0;
|
||
var hasCategories = cached.Categories.Count > 0;
|
||
|
||
if (hasGallery || hasCategories)
|
||
{
|
||
product = product with
|
||
{
|
||
Gallery = hasGallery ? cached.Gallery : product.Gallery,
|
||
Categories = hasCategories ? cached.Categories : product.Categories
|
||
};
|
||
}
|
||
}
|
||
|
||
return product;
|
||
}
|
||
|
||
private Product MapAndCache(GetProductsResponse model)
|
||
{
|
||
var gallery = model.Gallery
|
||
.Select(item => new ProductGalleryImage(
|
||
ProductGalleryId: item.ProductGalleryId,
|
||
ProductImageId: item.ProductImageId,
|
||
Title: item.Title ?? string.Empty,
|
||
ImageUrl: BuildUrl(item.ImagePath),
|
||
ThumbnailUrl: BuildUrl(item.ImageThumbnailPath)))
|
||
.ToList();
|
||
|
||
var product = new Product(
|
||
Id: model.Id,
|
||
Title: model.Title ?? string.Empty,
|
||
Description: model.Description ?? string.Empty,
|
||
FullInformation: model.FullInformation ?? string.Empty,
|
||
ImageUrl: BuildUrl(model.ImagePath),
|
||
Price: model.Price,
|
||
Discount: model.Discount,
|
||
Rate: model.Rate,
|
||
RemainingCount: model.RemainingCount)
|
||
{
|
||
Gallery = gallery,
|
||
Categories = MapCategoryPaths(model.Categories)
|
||
};
|
||
|
||
CacheProduct(product);
|
||
return product;
|
||
}
|
||
|
||
private void CacheProduct(Product product)
|
||
{
|
||
var entry = new CacheEntry(product, DateTime.UtcNow.Add(CacheDuration));
|
||
_cache.AddOrUpdate(product.Id, entry, (_, _) => entry);
|
||
}
|
||
|
||
private bool TryGetCachedProduct(long id, [NotNullWhen(true)] out Product? product)
|
||
{
|
||
if (_cache.TryGetValue(id, out var entry))
|
||
{
|
||
if (entry.Expiration > DateTime.UtcNow)
|
||
{
|
||
product = entry.Product;
|
||
return true;
|
||
}
|
||
|
||
_cache.TryRemove(id, out _);
|
||
}
|
||
|
||
product = null;
|
||
return false;
|
||
}
|
||
|
||
private IEnumerable<Product> GetValidCachedProducts()
|
||
{
|
||
var now = DateTime.UtcNow;
|
||
return _cache.Values
|
||
.Where(entry => entry.Expiration > now)
|
||
.Select(entry => entry.Product);
|
||
}
|
||
|
||
private static bool HasDetailedData(Product product)
|
||
=> product.Gallery.Count > 0 || product.Categories.Count > 0;
|
||
|
||
private sealed record CacheEntry(Product Product, DateTime Expiration);
|
||
private static string BuildUrl(string? path) =>
|
||
path ?? string.Empty;
|
||
|
||
private static IReadOnlyList<ProductCategoryPathInfo> MapCategoryPaths(IEnumerable<ProductCategoryPath>? categories)
|
||
{
|
||
if (categories is null)
|
||
{
|
||
return Array.Empty<ProductCategoryPathInfo>();
|
||
}
|
||
|
||
var result = new List<ProductCategoryPathInfo>();
|
||
foreach (var category in categories)
|
||
{
|
||
var nodes = category.Path
|
||
.Select(node => new ProductCategoryNodeInfo(
|
||
Id: node.Id,
|
||
Title: node.Title ?? string.Empty,
|
||
ParentId: node.ParentId
|
||
))
|
||
.ToList();
|
||
|
||
if (nodes.Count == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
result.Add(new ProductCategoryPathInfo(
|
||
CategoryId: category.CategoryId,
|
||
Title: category.Title ?? string.Empty,
|
||
Nodes: nodes));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
} |