5d58d354d7
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m50s
- Introduced an `_initialized` flag to manage product loading state more effectively. - Updated `OnParametersSetAsync` to conditionally load product details based on the initialization state and valid product ID. - Refactored product retrieval logic in `ProductService` to handle invalid IDs and improve fallback mechanisms for fetching product details. These changes improve the reliability and performance of the product detail component, ensuring that product information is loaded correctly and efficiently.
350 lines
11 KiB
C#
350 lines
11 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 (id <= 0)
|
||
return null;
|
||
|
||
TryGetCachedProduct(id, out var cached);
|
||
|
||
if (cached is not null && HasDetailedData(cached))
|
||
return cached;
|
||
|
||
// جزئیات کامل (گالری + دستهبندی)
|
||
var detailed = await TryFetchDetailAsync(id);
|
||
if (detailed is not null)
|
||
return detailed;
|
||
|
||
// fallback: همان API لیست محصولات — ناموجودها را هم برمیگرداند
|
||
var fromFilter = await TryFetchByFilterIdAsync(id);
|
||
if (fromFilter is not null)
|
||
return fromFilter;
|
||
|
||
return cached;
|
||
}
|
||
|
||
private async Task<Product?> TryFetchDetailAsync(long id)
|
||
{
|
||
try
|
||
{
|
||
var resp = await _client.GetProductsAsync(new GetProductsRequest { Id = id });
|
||
if (resp is null || resp.Id <= 0)
|
||
return null;
|
||
|
||
return MapAndCache(resp);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private async Task<Product?> TryFetchByFilterIdAsync(long id)
|
||
{
|
||
try
|
||
{
|
||
var resp = await _client.GetAllProductsByFilterAsync(new GetAllProductsByFilterRequest
|
||
{
|
||
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
|
||
{
|
||
PageNumber = 1,
|
||
PageSize = 1
|
||
},
|
||
Filter = new GetAllProductsByFilterFilter { Id = id }
|
||
});
|
||
|
||
var model = resp.Models.FirstOrDefault(m => m.Id == id);
|
||
if (model is null)
|
||
return null;
|
||
|
||
return MapAndCache(resp.Models).FirstOrDefault(p => p.Id == id);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
} |