更新 云仓商品库
This commit is contained in:
1 parent
ecba3b7ee6
commit
08757d15e4
28 files changed
+1866
-8
No files matched your search
@@ -1,5 +1,6 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Interfaces;
|
||||
using MAX.Core.Logger;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Dtos;
|
||||
using MAX.Framework.Interfaces;
|
||||
@@ -32,6 +33,8 @@ namespace MAX.Framework.DependencyInjection
|
||||
/// </summary>
|
||||
public static IServiceCollection AddStore(this IHostApplicationBuilder host)
|
||||
{
|
||||
//host.Services.AddSingleton<ILoggerFacade>(NLogger.Instance);//注册log
|
||||
|
||||
IEnumerable<Assembly> assemblies = GetApplicationAssemblies();
|
||||
// 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象
|
||||
Dictionary<Type, object> instances = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//using MAX.Core.Infrastructure;
|
||||
//using MAX.Core.Interfaces;
|
||||
//using MAX.Core.Logger;
|
||||
//using NLog;
|
||||
//using System;
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="NLog.Web.AspNetCore" Version="6.2.0" />
|
||||
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -44,10 +44,11 @@ namespace Store.Modules.CloudProduct
|
||||
//// 关键项缺失直接报错,避免 OpenSearch 地址为空、原始数据写到意外目录之类的隐性故障
|
||||
RequireConfiguration(configuration, "Search:Url");
|
||||
RequireConfiguration(configuration, "Search:IndexName");
|
||||
RequireConfiguration(configuration, "Search:FavoritesIndexName");
|
||||
RequireConfiguration(configuration, "RawData:Path");
|
||||
|
||||
// ===== Hangfire 后台定时任务(商品分词同步) =====
|
||||
var hangfireConnection = configuration.GetConnectionString("DefaultConnection")
|
||||
var hangfireConnection = configuration.GetValue<string>("CloudProductConnection")
|
||||
?? throw new InvalidOperationException("缺少配置 ConnectionStrings:DefaultConnection,CloudProduct 模块的 Hangfire 无法启动。");
|
||||
|
||||
// AllowUserVariables=true:Hangfire.MySqlStorage 的建表/查询语句依赖用户变量
|
||||
|
||||
@@ -314,7 +314,7 @@ namespace Store.Modules.CloudProduct.Controllers
|
||||
/// <summary>
|
||||
/// 读取指定商品详情:解压缩原始数据并返回 JSON。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[HttpPost]
|
||||
public async Task<JsonNode> GetGoodsDetail([FromQuery] long goodsId)
|
||||
{
|
||||
var raw = await GetRawDataOrThrowAsync(goodsId);
|
||||
@@ -325,7 +325,7 @@ namespace Store.Modules.CloudProduct.Controllers
|
||||
/// <summary>
|
||||
/// 读取指定商品的原始数据(解压缩后原样返回)。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[HttpPost]
|
||||
public async Task<string> GetGoodsRawData([FromQuery] long goodsId)
|
||||
{
|
||||
return await GetRawDataOrThrowAsync(goodsId);
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
using Asp.Versioning;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Dtos;
|
||||
using MAX.Framework.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Store.Framework.Base;
|
||||
using Store.Modules.CloudProduct.Dtos;
|
||||
using Store.Modules.CloudProduct.Services;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏商品:收藏/取消收藏(数据库 + 收藏索引同步)、收藏分组、收藏状态查询、收藏检索与索引重建。
|
||||
/// 请求中的 userName 允许为空,为空时取当前登录用户。
|
||||
/// </summary>
|
||||
[ApiVersionNeutral]
|
||||
[Route("api/CloudGoods/[action]")]
|
||||
public class UserFavoritesController : AppControllerBase
|
||||
{
|
||||
private readonly IUserFavoritesService _favoritesService;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public UserFavoritesController(IUserFavoritesService favoritesService, ICurrentUser currentUser)
|
||||
{
|
||||
_favoritesService = favoritesService;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量收藏商品:写入 user_goods_favorites 并同步写入收藏索引,返回本次成功收藏的商品数量。
|
||||
/// 商品已收藏时视为修改收藏分组;商品不存在/已删除的编号会被跳过。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<int> AddFavorite(AddUserFavoriteRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
request.UserName = ResolveUserName(request.UserName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.GoodsIds == null || request.GoodsIds.Count == 0)
|
||||
{
|
||||
throw new AppException("请提供要收藏的商品编号", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.GroupId < 0)
|
||||
{
|
||||
throw new AppException("收藏分组无效", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
return await _favoritesService.AddFavoritesAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量取消收藏:删除 user_goods_favorites 记录并同步删除收藏索引文档,返回实际取消的条数。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<int> RemoveFavorites(RemoveUserFavoritesRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
request.UserName = ResolveUserName(request.UserName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.GoodsIds == null || request.GoodsIds.Count == 0)
|
||||
{
|
||||
throw new AppException("请提供要取消收藏的商品编号", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
return await _favoritesService.RemoveFavoritesAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建收藏分组:创建成功返回 true,同名分组已存在返回 false。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<bool> CreateGroup(CreateUserFavoriteGroupRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
request.UserName = ResolveUserName(request.UserName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.GroupName))
|
||||
{
|
||||
throw new AppException("分组名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
return await _favoritesService.CreateGroupAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询用户的收藏分组(含默认分组)及分组下收藏数量。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<List<UserFavoriteGroupDto>> GetGroups(string? userName = null)
|
||||
{
|
||||
var resolved = ResolveUserName(userName);
|
||||
if (string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
return await _favoritesService.GetGroupsAsync(resolved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 GoodsIds 顺序返回每个商品是否已被收藏(如 [true,false,...]),前台可按顺序直接渲染。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<List<bool>> GetFavoriteStatus(UserFavoriteStatusRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
request.UserName = ResolveUserName(request.UserName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
return await _favoritesService.GetFavoriteStatusAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检索用户收藏的商品(标题、销量、价格、类目、分组)。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<PageData<UserFavoriteItem>> SearchFavorites(UserFavoritesSearchRequest request)
|
||||
{
|
||||
// 1. 参数基础校验
|
||||
if (request == null)
|
||||
{
|
||||
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
request.UserName = ResolveUserName(request.UserName);
|
||||
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
// 2. 分页参数校验
|
||||
if (request.PageIndex < 1)
|
||||
{
|
||||
throw new AppException("页码必须大于0", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.PageSize < 1 || request.PageSize > 2000)
|
||||
{
|
||||
throw new AppException("每页数量必须在1-2000之间", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
// 3. 价格区间校验
|
||||
if (request.MinPrice.HasValue && request.MinPrice < 0)
|
||||
{
|
||||
throw new AppException("最低价格不能小于0", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.MaxPrice.HasValue && request.MaxPrice < 0)
|
||||
{
|
||||
throw new AppException("最高价格不能小于0", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.MinPrice.HasValue && request.MaxPrice.HasValue && request.MinPrice > request.MaxPrice)
|
||||
{
|
||||
throw new AppException("最低价格不能大于最高价格", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
// 4. 销量区间校验
|
||||
if (request.MinSoldQuantity.HasValue && request.MinSoldQuantity < 0)
|
||||
{
|
||||
throw new AppException("最低销量不能小于0", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.MaxSoldQuantity.HasValue && request.MaxSoldQuantity < 0)
|
||||
{
|
||||
throw new AppException("最高销量不能小于0", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.MinSoldQuantity.HasValue && request.MaxSoldQuantity.HasValue &&
|
||||
request.MinSoldQuantity > request.MaxSoldQuantity)
|
||||
{
|
||||
throw new AppException("最低销量不能大于最高销量", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
// 5. 类目层级校验
|
||||
if (request.CatId2.HasValue && !request.CatId1.HasValue)
|
||||
{
|
||||
throw new AppException("选择二级类目时必须先选择一级类目", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.CatId3.HasValue && !request.CatId2.HasValue)
|
||||
{
|
||||
throw new AppException("选择三级类目时必须先选择二级类目", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.CatId4.HasValue && !request.CatId3.HasValue)
|
||||
{
|
||||
throw new AppException("选择四级类目时必须先选择三级类目", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
// 6. 分组校验
|
||||
if (request.GroupId.HasValue && request.GroupId < 0)
|
||||
{
|
||||
throw new AppException("收藏分组无效", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
// 7. 平台校验
|
||||
if (request.Platform.HasValue && request.Platform < 0)
|
||||
{
|
||||
throw new AppException("平台类型无效", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var result = await _favoritesService.SearchFavoritesAsync(request);
|
||||
|
||||
return new PageData<UserFavoriteItem>
|
||||
{
|
||||
List = result.List,
|
||||
Total = result.Total,
|
||||
PageSize = request.PageSize,
|
||||
PageIndex = request.PageIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除所有用户收藏索引并重新创建,随后按 user_goods_favorites 关联 goods 全量回填。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<long> RebuildSearchIndex()
|
||||
{
|
||||
return await _favoritesService.RebuildSearchIndexAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取用户名:显式传入优先,否则取当前登录用户。
|
||||
/// </summary>
|
||||
private string ResolveUserName(string? userName)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(userName)
|
||||
? (_currentUser.UserName ?? string.Empty)
|
||||
: userName.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 收藏商品请求(支持多选批量收藏)
|
||||
/// </summary>
|
||||
public class AddUserFavoriteRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户名(为空时取当前登录用户)
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品编号集合(可多选)
|
||||
/// </summary>
|
||||
public List<long> GoodsIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 收藏分组ID(默认0,表示默认分组;大于0时必须存在且属于该用户)
|
||||
/// </summary>
|
||||
public int GroupId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建收藏分组请求
|
||||
/// </summary>
|
||||
public class CreateUserFavoriteGroupRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户名(为空时取当前登录用户)
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分组名
|
||||
/// </summary>
|
||||
public string GroupName { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 取消收藏(批量)请求
|
||||
/// </summary>
|
||||
public class RemoveUserFavoritesRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户名(为空时取当前登录用户)
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 要取消收藏的商品编号集合
|
||||
/// </summary>
|
||||
public List<long> GoodsIds { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏分组
|
||||
/// </summary>
|
||||
public class UserFavoriteGroupDto
|
||||
{
|
||||
/// <summary>分组ID</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>用户名</summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>分组名</summary>
|
||||
public string GroupName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>该分组下的收藏数量</summary>
|
||||
public long FavoriteCount { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏商品(索引文档回显模型)
|
||||
/// </summary>
|
||||
public class UserFavoriteItem
|
||||
{
|
||||
/// <summary>收藏记录自增主键</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>用户名</summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>收藏分组ID(0 表示默认分组)</summary>
|
||||
public int GroupId { get; set; }
|
||||
|
||||
/// <summary>收藏分组名</summary>
|
||||
public string GroupName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>商品编号</summary>
|
||||
public long GoodsId { get; set; }
|
||||
|
||||
/// <summary>平台类型</summary>
|
||||
public byte Platform { get; set; }
|
||||
|
||||
/// <summary>商品标题</summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>缩略图</summary>
|
||||
public string? ThumbUrl { get; set; }
|
||||
|
||||
/// <summary>一级类目ID</summary>
|
||||
public int? CatId1 { get; set; }
|
||||
|
||||
/// <summary>二级类目ID</summary>
|
||||
public int? CatId2 { get; set; }
|
||||
|
||||
/// <summary>三级类目ID</summary>
|
||||
public int? CatId3 { get; set; }
|
||||
|
||||
/// <summary>四级类目ID</summary>
|
||||
public int? CatId4 { get; set; }
|
||||
|
||||
/// <summary>最低价快照(分)</summary>
|
||||
public long? MinPrice { get; set; }
|
||||
|
||||
/// <summary>销量</summary>
|
||||
public int SoldQuantity { get; set; }
|
||||
|
||||
/// <summary>收藏时间</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询商品是否已被收藏请求:按 GoodsIds 顺序返回是否已收藏
|
||||
/// </summary>
|
||||
public class UserFavoriteStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户名(为空时取当前登录用户)
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品编号集合
|
||||
/// </summary>
|
||||
public List<long> GoodsIds { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using OpenSearch.Client;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏商品搜索文档:OpenSearch 索引中的收藏模型。
|
||||
/// 一条收藏记录 = 用户 + 商品快照(标题/价格/销量/类目)+ 收藏分组,
|
||||
/// 文档 Id 固定为 "{user_name}:{goods_id}"(表 uk_user_goods 保证唯一),便于收藏/取消收藏时幂等覆盖与删除。
|
||||
/// </summary>
|
||||
public class UserFavoritesSearchDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// 文档主键(同时作为 OpenSearch 的 _id):{user_name}:{goods_id}
|
||||
/// </summary>
|
||||
[Keyword(Name = "id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 收藏记录自增主键(user_goods_favorites.id)
|
||||
/// </summary>
|
||||
[Number(NumberType.Long, Name = "fav_id")]
|
||||
public long FavId { get; set; }
|
||||
|
||||
/// <summary>用户名(收藏归属人,检索时强制过滤)</summary>
|
||||
[Keyword(Name = "user_name")]
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>收藏分组ID(0 表示默认分组)</summary>
|
||||
[Number(NumberType.Integer, Name = "group_id")]
|
||||
public int GroupId { get; set; }
|
||||
|
||||
/// <summary>收藏分组名(分组ID为0时为默认分组名)</summary>
|
||||
[Keyword(Name = "group_name")]
|
||||
public string GroupName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>商品编号</summary>
|
||||
[Number(NumberType.Long, Name = "goods_id")]
|
||||
public long GoodsId { get; set; }
|
||||
|
||||
/// <summary>平台类型(1 PDD、2 WX)</summary>
|
||||
[Number(NumberType.Byte, Name = "platform")]
|
||||
public byte Platform { get; set; }
|
||||
|
||||
/// <summary>商品标题,用于分词搜索</summary>
|
||||
[Text(Name = "title", Analyzer = "ik_max_word", SearchAnalyzer = "ik_smart")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>缩略图</summary>
|
||||
[Text(Name = "thumb_url")]
|
||||
public string? ThumbUrl { get; set; }
|
||||
|
||||
/// <summary>一级类目ID</summary>
|
||||
[Number(NumberType.Integer, Name = "cat_id_1")]
|
||||
public int? CatId1 { get; set; }
|
||||
|
||||
/// <summary>二级类目ID</summary>
|
||||
[Number(NumberType.Integer, Name = "cat_id_2")]
|
||||
public int? CatId2 { get; set; }
|
||||
|
||||
/// <summary>三级类目ID</summary>
|
||||
[Number(NumberType.Integer, Name = "cat_id_3")]
|
||||
public int? CatId3 { get; set; }
|
||||
|
||||
/// <summary>四级类目ID</summary>
|
||||
[Number(NumberType.Integer, Name = "cat_id_4")]
|
||||
public int? CatId4 { get; set; }
|
||||
|
||||
/// <summary>最低价快照(分),用于价格区间过滤和按价格排序</summary>
|
||||
[Number(NumberType.Long, Name = "min_price")]
|
||||
public long? MinPrice { get; set; }
|
||||
|
||||
/// <summary>销量,用于按销量排序</summary>
|
||||
[Number(NumberType.Integer, Name = "sold_quantity")]
|
||||
public int SoldQuantity { get; set; }
|
||||
|
||||
/// <summary>收藏时间,默认按此字段倒序</summary>
|
||||
[Date(Name = "created_at")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 生成文档主键:{user_name}:{goods_id}
|
||||
/// </summary>
|
||||
public static string BuildId(string userName, long goodsId) => $"{userName}:{goodsId}";
|
||||
|
||||
public override string ToString() => $"[Id={Id}] User={UserName} Goods={GoodsId} Group={GroupId}({GroupName}) Title={Title} Price={MinPrice} Sold={SoldQuantity}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace Store.Modules.CloudProduct.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏商品搜索请求(仅检索当前用户自己的收藏)
|
||||
/// </summary>
|
||||
public class UserFavoritesSearchRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户名(为空时取当前登录用户)
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标题关键字(分词模糊搜索)
|
||||
/// </summary>
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 收藏分组ID(0 表示默认分组)
|
||||
/// </summary>
|
||||
public int? GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 收藏分组名(与分组ID二选一,同时传入时以分组ID为准)
|
||||
/// </summary>
|
||||
public string? GroupName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最低价格(分)
|
||||
/// </summary>
|
||||
public long? MinPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最高价格(分)
|
||||
/// </summary>
|
||||
public long? MaxPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 一级类目ID
|
||||
/// </summary>
|
||||
public int? CatId1 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 二级类目ID
|
||||
/// </summary>
|
||||
public int? CatId2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 三级类目ID
|
||||
/// </summary>
|
||||
public int? CatId3 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 四级类目ID
|
||||
/// </summary>
|
||||
public int? CatId4 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最低销量
|
||||
/// </summary>
|
||||
public int? MinSoldQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最高销量
|
||||
/// </summary>
|
||||
public int? MaxSoldQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 平台类型
|
||||
/// </summary>
|
||||
public byte? Platform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码(默认1)
|
||||
/// </summary>
|
||||
public int PageIndex { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页数量(默认20)
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 排序方式:CreatedTimeDesc(默认,收藏时间倒序)/ CreatedTimeAsc / PriceAsc / PriceDesc / SoldDesc
|
||||
/// </summary>
|
||||
public string SortBy { get; set; } = "CreatedTimeDesc";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏商品表实体(对应数据库表 user_goods_favorites)
|
||||
/// </summary>
|
||||
[Table(Name = "user_goods_favorites")]
|
||||
public class UserGoodsFavoriteEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 自增主键
|
||||
/// </summary>
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||
[JsonIgnore]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户名(同一用户不能重复收藏同一商品)
|
||||
/// </summary>
|
||||
[Column(Name = "user_name", IsNullable = false, StringLength = 50)]
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 收藏分组ID(0 表示默认分组),对应 user_goods_favorites_group.id
|
||||
/// </summary>
|
||||
[Column(Name = "group_id", IsNullable = false)]
|
||||
public int GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品编号,对应 goods.goods_id
|
||||
/// </summary>
|
||||
[Column(Name = "goods_id", IsNullable = false)]
|
||||
public long GoodsId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 收藏时间
|
||||
/// </summary>
|
||||
[Column(Name = "created_at", IsNullable = false)]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏分组表实体(对应数据库表 user_goods_favorites_group)
|
||||
/// </summary>
|
||||
[Table(Name = "user_goods_favorites_group")]
|
||||
public class UserGoodsFavoriteGroupEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 自增主键,即收藏表 user_goods_favorites.group_id 的取值
|
||||
/// </summary>
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||
[JsonIgnore]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户名
|
||||
/// </summary>
|
||||
[Column(Name = "user_name", IsNullable = false, StringLength = 50)]
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分组名
|
||||
/// </summary>
|
||||
[Column(Name = "group_name", IsNullable = false, StringLength = 255)]
|
||||
public string GroupName { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Store.Modules.CloudProduct.Enums
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏商品搜索排序方式
|
||||
/// </summary>
|
||||
public enum UserFavoritesSortBy
|
||||
{
|
||||
/// <summary>按收藏时间倒序(默认)</summary>
|
||||
CreatedTimeDesc = 0,
|
||||
|
||||
/// <summary>按收藏时间正序</summary>
|
||||
CreatedTimeAsc = 1,
|
||||
|
||||
/// <summary>价格从低到高</summary>
|
||||
PriceAsc = 2,
|
||||
|
||||
/// <summary>价格从高到低</summary>
|
||||
PriceDesc = 3,
|
||||
|
||||
/// <summary>销量从高到低</summary>
|
||||
SoldDesc = 4,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using FreeSql;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Repositorys
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏分组仓储(对应 user_goods_favorites_group 表)
|
||||
/// </summary>
|
||||
public class UserGoodsFavoriteGroupRepository : BaseRepository<UserGoodsFavoriteGroupEntity>, IScopedDependency
|
||||
{
|
||||
public UserGoodsFavoriteGroupRepository([FromKeyedServices("CloudProductConnection")] IFreeSql fsql) : base(fsql)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using FreeSql;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Repositorys
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏商品仓储(对应 user_goods_favorites 表)
|
||||
/// </summary>
|
||||
public class UserGoodsFavoriteRepository : BaseRepository<UserGoodsFavoriteEntity>, IScopedDependency
|
||||
{
|
||||
public UserGoodsFavoriteRepository([FromKeyedServices("CloudProductConnection")] IFreeSql fsql) : base(fsql)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Dtos;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenSearch.Client;
|
||||
using OpenSearch.Net;
|
||||
using Store.Modules.CloudProduct.Dtos;
|
||||
using Store.Modules.CloudProduct.Enums;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏商品索引服务:负责收藏索引(Search:FavoritesIndexName)的
|
||||
/// 创建、写入、删除、重建与检索,索引文档由 user_goods_favorites 关联 goods 补全而来。
|
||||
/// </summary>
|
||||
public interface IUserFavoritesIndexService : ISingletonDependency
|
||||
{
|
||||
/// <summary>检查索引是否存在</summary>
|
||||
Task<bool> IndexExistsAsync();
|
||||
|
||||
/// <summary>检查索引映射(分析器)是否符合当前服务要求</summary>
|
||||
Task<bool> IsIndexMappingHealthyAsync();
|
||||
|
||||
/// <summary>创建索引(含 IK 分词映射;已存在且映射正常时跳过)</summary>
|
||||
Task<bool> CreateIndexAsync();
|
||||
|
||||
/// <summary>删除并重建索引(映射统一,重建期间该索引不可查)</summary>
|
||||
Task<bool> ClearIndexAsync();
|
||||
|
||||
/// <summary>批量写入收藏文档(存在则覆盖)</summary>
|
||||
Task IndexDocumentsAsync(IEnumerable<UserFavoritesSearchDocument> documents);
|
||||
|
||||
/// <summary>按文档Id批量删除收藏文档</summary>
|
||||
Task DeleteDocumentsAsync(IEnumerable<string> documentIds);
|
||||
|
||||
/// <summary>统计索引文档数</summary>
|
||||
Task<long> CountDocumentsAsync();
|
||||
|
||||
/// <summary>按用户检索收藏商品(标题/销量/价格/类目/分组)</summary>
|
||||
Task<(long Total, List<UserFavoritesSearchDocument> Documents)> SearchAsync(UserFavoritesSearchRequest request, UserFavoritesSortBy sortBy);
|
||||
}
|
||||
|
||||
internal class UserFavoritesIndexService : IUserFavoritesIndexService
|
||||
{
|
||||
private readonly string _nodeUrl;
|
||||
private readonly string _indexName;
|
||||
private readonly IOpenSearchClient _client = null!;
|
||||
private readonly ILogger<UserFavoritesIndexService> _logger;
|
||||
|
||||
// 查询类操作(search/count/exists)统一超时
|
||||
private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(5);
|
||||
// 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长)
|
||||
private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
public UserFavoritesIndexService(IConfiguration configuration, ILogger<UserFavoritesIndexService> logger)
|
||||
{
|
||||
var config = configuration.GetSection("Search");
|
||||
_nodeUrl = config.GetValue<string>("Url") ?? "";
|
||||
_indexName = config.GetValue<string>("FavoritesIndexName") ?? "";
|
||||
_logger = logger;
|
||||
_client = CreateClient();
|
||||
}
|
||||
|
||||
private IOpenSearchClient CreateClient()
|
||||
{
|
||||
var pool = new SingleNodeConnectionPool(new Uri(_nodeUrl));
|
||||
var settings = new ConnectionSettings(pool)
|
||||
.DefaultIndex(_indexName)
|
||||
.DefaultMappingFor<UserFavoritesSearchDocument>(m => m.IndexName(_indexName))
|
||||
.RequestTimeout(QueryTimeout)
|
||||
.MaxRetryTimeout(QueryTimeout)
|
||||
.PrettyJson()
|
||||
.DisableDirectStreaming();
|
||||
|
||||
return new OpenSearchClient(settings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 1. 索引是否存在 ============
|
||||
/// </summary>
|
||||
public async Task<bool> IndexExistsAsync()
|
||||
{
|
||||
var response = await _client.Indices.ExistsAsync(_indexName, d => d
|
||||
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
|
||||
return response.Exists;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 2. 索引映射是否健康 ============
|
||||
/// </summary>
|
||||
public async Task<bool> IsIndexMappingHealthyAsync()
|
||||
{
|
||||
var getResponse = await _client.Indices.GetAsync(_indexName, d => d
|
||||
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
|
||||
if (!getResponse.IsValid)
|
||||
{
|
||||
throw new AppException("读取收藏索引映射失败: " + getResponse.DebugInformation);
|
||||
}
|
||||
if (!getResponse.Indices.TryGetValue(_indexName, out var indexState))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mapping = indexState.Mappings;
|
||||
if (mapping?.Properties == null
|
||||
|| !mapping.Properties.TryGetValue(new PropertyName("title"), out var property)
|
||||
|| property is not ITextProperty textProperty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var indexAnalyzer = textProperty.Analyzer;
|
||||
var searchAnalyzer = textProperty.SearchAnalyzer;
|
||||
var usesIk = indexAnalyzer == "ik_max_word" && searchAnalyzer == "ik_smart";
|
||||
var usesStandard = indexAnalyzer == "standard" && searchAnalyzer == "standard";
|
||||
return usesIk || usesStandard;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 3. 创建索引 ============
|
||||
/// </summary>
|
||||
public async Task<bool> CreateIndexAsync()
|
||||
{
|
||||
var existsResponse = await _client.Indices.ExistsAsync(_indexName, d => d
|
||||
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
|
||||
if (existsResponse.Exists)
|
||||
{
|
||||
if (await IsIndexMappingHealthyAsync())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning("收藏索引 [{Index}] 的映射不完整,正在重建...", _indexName);
|
||||
var deleteResponse = await _client.Indices.DeleteAsync(_indexName, d => d
|
||||
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
|
||||
if (!deleteResponse.IsValid)
|
||||
{
|
||||
throw new AppException("删除损坏的收藏索引失败: " + deleteResponse.DebugInformation);
|
||||
}
|
||||
}
|
||||
|
||||
// 优先尝试使用 IK 中文分词(需集群安装 analysis-ik 插件)
|
||||
var createResponse = await TryCreateIndexAsync(useIk: true);
|
||||
|
||||
if (!createResponse.IsValid)
|
||||
{
|
||||
_logger.LogWarning("IK 分词插件不可用,收藏索引已回退到 standard 分析器,中文分词将失效。");
|
||||
createResponse = await TryCreateIndexAsync(useIk: false);
|
||||
}
|
||||
|
||||
if (!createResponse.IsValid)
|
||||
{
|
||||
throw new AppException("收藏索引创建失败: " + createResponse.DebugInformation);
|
||||
}
|
||||
|
||||
_logger.LogInformation("收藏索引 [{Index}] 创建成功。", _indexName);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<CreateIndexResponse> TryCreateIndexAsync(bool useIk)
|
||||
{
|
||||
var indexAnalyzer = useIk ? "ik_max_word" : "standard";
|
||||
var searchAnalyzer = useIk ? "ik_smart" : "standard";
|
||||
|
||||
return await _client.Indices.CreateAsync(_indexName, c => c
|
||||
.Settings(s => s
|
||||
.NumberOfShards(1)
|
||||
.NumberOfReplicas(0)
|
||||
)
|
||||
.Map<UserFavoritesSearchDocument>(m => m
|
||||
.Properties(p => p
|
||||
.Keyword(k => k.Name(x => x.Id))
|
||||
.Number(n => n.Name(x => x.FavId).Type(NumberType.Long))
|
||||
.Keyword(k => k.Name(x => x.UserName))
|
||||
.Number(n => n.Name(x => x.GroupId).Type(NumberType.Integer))
|
||||
.Keyword(k => k.Name(x => x.GroupName))
|
||||
.Number(n => n.Name(x => x.GoodsId).Type(NumberType.Long))
|
||||
.Number(n => n.Name(x => x.Platform).Type(NumberType.Byte))
|
||||
.Text(t => t
|
||||
.Name(x => x.Title)
|
||||
.Analyzer(indexAnalyzer)
|
||||
.SearchAnalyzer(searchAnalyzer)
|
||||
)
|
||||
.Text(t => t.Name(x => x.ThumbUrl))
|
||||
.Number(n => n.Name(x => x.CatId1).Type(NumberType.Integer))
|
||||
.Number(n => n.Name(x => x.CatId2).Type(NumberType.Integer))
|
||||
.Number(n => n.Name(x => x.CatId3).Type(NumberType.Integer))
|
||||
.Number(n => n.Name(x => x.CatId4).Type(NumberType.Integer))
|
||||
.Number(n => n.Name(x => x.MinPrice).Type(NumberType.Long))
|
||||
.Number(n => n.Name(x => x.SoldQuantity).Type(NumberType.Integer))
|
||||
.Date(d => d.Name(x => x.CreatedAt))
|
||||
)
|
||||
)
|
||||
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout))
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 4. 删除并重建索引 ============
|
||||
/// </summary>
|
||||
public async Task<bool> ClearIndexAsync()
|
||||
{
|
||||
var existsResponse = await _client.Indices.ExistsAsync(_indexName, d => d
|
||||
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
|
||||
if (!existsResponse.Exists)
|
||||
{
|
||||
return await CreateIndexAsync();
|
||||
}
|
||||
|
||||
var deleteResponse = await _client.Indices.DeleteAsync(_indexName, d => d
|
||||
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
|
||||
if (!deleteResponse.IsValid)
|
||||
{
|
||||
throw new AppException($"删除收藏索引 {_indexName} 失败: " + deleteResponse.DebugInformation);
|
||||
}
|
||||
|
||||
_logger.LogInformation("收藏索引 [{Index}] 已删除,准备重建。", _indexName);
|
||||
return await CreateIndexAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshIndexAsync()
|
||||
{
|
||||
var response = await _client.Indices.RefreshAsync(_indexName, d => d
|
||||
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
|
||||
if (!response.IsValid)
|
||||
{
|
||||
throw new AppException("刷新收藏索引失败: " + response.DebugInformation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 5. 批量写入收藏文档(存在则覆盖) ============
|
||||
/// </summary>
|
||||
public async Task IndexDocumentsAsync(IEnumerable<UserFavoritesSearchDocument> documents)
|
||||
{
|
||||
if (documents == null) throw new ArgumentNullException(nameof(documents));
|
||||
|
||||
var list = documents.Where(x => x != null && string.IsNullOrWhiteSpace(x.Id) == false).ToList();
|
||||
if (list.Count == 0) return;
|
||||
|
||||
await CreateIndexAsync();
|
||||
|
||||
var bulkResponse = await _client.BulkAsync(b => b
|
||||
.Index(_indexName)
|
||||
.IndexMany(list, (descriptor, doc) => descriptor.Id(doc.Id))
|
||||
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout))
|
||||
);
|
||||
|
||||
if (!bulkResponse.IsValid)
|
||||
{
|
||||
throw new AppException("批量写入收藏索引失败: " + bulkResponse.DebugInformation);
|
||||
}
|
||||
|
||||
await RefreshIndexAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 6. 按文档Id批量删除收藏文档 ============
|
||||
/// </summary>
|
||||
public async Task DeleteDocumentsAsync(IEnumerable<string> documentIds)
|
||||
{
|
||||
var list = documentIds?.Where(x => string.IsNullOrWhiteSpace(x) == false).Distinct().ToList() ?? new List<string>();
|
||||
if (list.Count == 0) return;
|
||||
|
||||
if (await IndexExistsAsync() == false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var response = await _client.BulkAsync(b =>
|
||||
{
|
||||
foreach (var id in list)
|
||||
{
|
||||
b.Delete<UserFavoritesSearchDocument>(d => d.Index(_indexName).Id(id));
|
||||
}
|
||||
b.RequestConfiguration(r => r.RequestTimeout(AdminTimeout));
|
||||
return b;
|
||||
});
|
||||
|
||||
if (!response.IsValid)
|
||||
{
|
||||
throw new AppException("删除收藏索引文档失败: " + response.DebugInformation);
|
||||
}
|
||||
|
||||
await RefreshIndexAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 7. 统计文档数 ============
|
||||
/// </summary>
|
||||
public async Task<long> CountDocumentsAsync()
|
||||
{
|
||||
var countResponse = await _client.CountAsync<UserFavoritesSearchDocument>(c => c
|
||||
.Index(_indexName)
|
||||
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout))
|
||||
);
|
||||
if (!countResponse.IsValid)
|
||||
{
|
||||
throw new AppException("统计收藏索引失败: " + countResponse.DebugInformation);
|
||||
}
|
||||
|
||||
return countResponse.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ============ 8. 检索用户收藏商品(标题/价格/销量/类目/分组 + 排序 + 分页) ============
|
||||
/// </summary>
|
||||
public async Task<(long Total, List<UserFavoritesSearchDocument> Documents)> SearchAsync(UserFavoritesSearchRequest request, UserFavoritesSortBy sortBy)
|
||||
{
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var keyword = request.Title;
|
||||
int? groupId = request.GroupId;
|
||||
var groupName = request.GroupName;
|
||||
int pageIndex = request.PageIndex;
|
||||
int pageSize = request.PageSize;
|
||||
|
||||
if (pageIndex < 1) pageIndex = 1;
|
||||
if (pageSize < 1) pageSize = 20;
|
||||
if (pageSize > 2000) pageSize = 2000; // 避免深分页/单页过大拖垮性能
|
||||
|
||||
var from = (pageIndex - 1) * pageSize;
|
||||
|
||||
ISearchResponse<UserFavoritesSearchDocument> searchResponse;
|
||||
try
|
||||
{
|
||||
searchResponse = await _client.SearchAsync<UserFavoritesSearchDocument>(s => s
|
||||
.Index(_indexName)
|
||||
.Query(q => q
|
||||
.Bool(b =>
|
||||
{
|
||||
// 只检索当前用户自己的收藏
|
||||
b.Filter(f => f.Term(t => t.Field(x => x.UserName).Value(request.UserName)));
|
||||
|
||||
var filters = new List<Func<QueryContainerDescriptor<UserFavoritesSearchDocument>, QueryContainer>>();
|
||||
|
||||
if (groupId.HasValue)
|
||||
{
|
||||
filters.Add(f => f.Term(t => t.Field(x => x.GroupId).Value(groupId.Value)));
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(groupName) == false)
|
||||
{
|
||||
filters.Add(f => f.Term(t => t.Field(x => x.GroupName).Value(groupName)));
|
||||
}
|
||||
|
||||
if (request.Platform.HasValue && request.Platform.Value > 0)
|
||||
{
|
||||
filters.Add(f => f.Term(t => t.Field(x => x.Platform).Value(request.Platform.Value)));
|
||||
}
|
||||
|
||||
if (request.CatId1.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId1).Value(request.CatId1.Value)));
|
||||
if (request.CatId2.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId2).Value(request.CatId2.Value)));
|
||||
if (request.CatId3.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId3).Value(request.CatId3.Value)));
|
||||
if (request.CatId4.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId4).Value(request.CatId4.Value)));
|
||||
|
||||
if (request.MinPrice.HasValue || request.MaxPrice.HasValue)
|
||||
{
|
||||
filters.Add(f => f.Range(r =>
|
||||
{
|
||||
var range = r.Field(x => x.MinPrice);
|
||||
if (request.MinPrice.HasValue) range = range.GreaterThanOrEquals(request.MinPrice.Value);
|
||||
if (request.MaxPrice.HasValue) range = range.LessThanOrEquals(request.MaxPrice.Value);
|
||||
return range;
|
||||
}));
|
||||
}
|
||||
|
||||
if (request.MinSoldQuantity.HasValue || request.MaxSoldQuantity.HasValue)
|
||||
{
|
||||
filters.Add(f => f.Range(r =>
|
||||
{
|
||||
var range = r.Field(x => x.SoldQuantity);
|
||||
if (request.MinSoldQuantity.HasValue) range = range.GreaterThanOrEquals(request.MinSoldQuantity.Value);
|
||||
if (request.MaxSoldQuantity.HasValue) range = range.LessThanOrEquals(request.MaxSoldQuantity.Value);
|
||||
return range;
|
||||
}));
|
||||
}
|
||||
|
||||
if (filters.Count > 0) b.Filter(filters.ToArray());
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyword) == false)
|
||||
{
|
||||
b.Must(m => m.Match(mm => mm.Field(f => f.Title).Query(keyword)));
|
||||
}
|
||||
|
||||
return b;
|
||||
})
|
||||
)
|
||||
.Sort(sort => sortBy switch
|
||||
{
|
||||
UserFavoritesSortBy.CreatedTimeAsc => sort.Ascending(f => f.CreatedAt),
|
||||
UserFavoritesSortBy.PriceAsc => sort.Ascending(f => f.MinPrice),
|
||||
UserFavoritesSortBy.PriceDesc => sort.Descending(f => f.MinPrice),
|
||||
UserFavoritesSortBy.SoldDesc => sort.Descending(f => f.SoldQuantity),
|
||||
_ => sort.Descending(f => f.CreatedAt)
|
||||
})
|
||||
.From(from)
|
||||
.Size(pageSize)
|
||||
.TrackTotalHits(true) // 命中数超过 10000 时默认不精确统计,需要显式开启
|
||||
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout))
|
||||
);
|
||||
}
|
||||
catch (OpenSearchClientException ex) when (ex.Response?.Success == false)
|
||||
{
|
||||
throw new AppException($"收藏检索超时或失败({QueryTimeout.TotalSeconds}秒): " + ex.Message);
|
||||
}
|
||||
|
||||
if (!searchResponse.IsValid)
|
||||
{
|
||||
throw new AppException("收藏检索失败: " + searchResponse.DebugInformation);
|
||||
}
|
||||
|
||||
return (searchResponse.Total, searchResponse.Documents.ToList());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Dtos;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Store.Modules.CloudProduct.Dtos;
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
using Store.Modules.CloudProduct.Enums;
|
||||
using Store.Modules.CloudProduct.Repositorys;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户收藏商品业务:收藏/取消收藏(数据库 + 收藏索引同步写入)、收藏分组维护、收藏状态查询、收藏检索与索引重建。
|
||||
/// </summary>
|
||||
public interface IUserFavoritesService : IScopedDependency
|
||||
{
|
||||
/// <summary>批量收藏商品,返回本次成功收藏的商品数量(已收藏的商品视为修改收藏分组)</summary>
|
||||
Task<int> AddFavoritesAsync(AddUserFavoriteRequest request);
|
||||
|
||||
/// <summary>批量取消收藏,返回实际取消的条数</summary>
|
||||
Task<int> RemoveFavoritesAsync(RemoveUserFavoritesRequest request);
|
||||
|
||||
/// <summary>创建收藏分组:创建成功返回 true,同名分组已存在返回 false</summary>
|
||||
Task<bool> CreateGroupAsync(CreateUserFavoriteGroupRequest request);
|
||||
|
||||
/// <summary>查询用户的收藏分组(含默认分组)及分组下收藏数量</summary>
|
||||
Task<List<UserFavoriteGroupDto>> GetGroupsAsync(string userName);
|
||||
|
||||
/// <summary>按 GoodsIds 顺序返回每个商品是否已被该用户收藏</summary>
|
||||
Task<List<bool>> GetFavoriteStatusAsync(UserFavoriteStatusRequest request);
|
||||
|
||||
/// <summary>检索用户收藏的商品(标题/销量/价格/类目/分组)</summary>
|
||||
Task<(List<UserFavoriteItem> List, long Total)> SearchFavoritesAsync(UserFavoritesSearchRequest request);
|
||||
|
||||
/// <summary>移除所有用户收藏索引并按数据库数据全量重建,返回重建的文档数</summary>
|
||||
Task<long> RebuildSearchIndexAsync();
|
||||
}
|
||||
|
||||
internal class UserFavoritesService : IUserFavoritesService
|
||||
{
|
||||
/// <summary>未归属任何分组的收藏所使用的分组名(对应 group_id = 0)</summary>
|
||||
public const string DefaultGroupName = "默认分组";
|
||||
|
||||
/// <summary>重建索引时单次 bulk 的文档数,避免一次请求过大</summary>
|
||||
private const int RebuildBatchSize = 2000;
|
||||
|
||||
private readonly UserGoodsFavoriteRepository _favoriteRepository;
|
||||
private readonly UserGoodsFavoriteGroupRepository _groupRepository;
|
||||
private readonly GoodsRepository _goodsRepository;
|
||||
private readonly IUserFavoritesIndexService _indexService;
|
||||
private readonly ILogger<UserFavoritesService> _logger;
|
||||
|
||||
public UserFavoritesService(
|
||||
UserGoodsFavoriteRepository favoriteRepository,
|
||||
UserGoodsFavoriteGroupRepository groupRepository,
|
||||
GoodsRepository goodsRepository,
|
||||
IUserFavoritesIndexService indexService,
|
||||
ILogger<UserFavoritesService> logger)
|
||||
{
|
||||
_favoriteRepository = favoriteRepository;
|
||||
_groupRepository = groupRepository;
|
||||
_goodsRepository = goodsRepository;
|
||||
_indexService = indexService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量收藏商品:先落库(user_goods_favorites),再同步写入收藏索引;
|
||||
/// 商品不存在/已删除的编号会被跳过,返回本次成功收藏的商品数量。
|
||||
/// 任意一步失败都会回滚本次已写入的数据库记录,保证“数据库中有收藏 = 索引中可搜索到”。
|
||||
/// </summary>
|
||||
public async Task<int> AddFavoritesAsync(AddUserFavoriteRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var userName = request.UserName?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var goodsIds = request.GoodsIds?.Where(x => x > 0).Distinct().ToList() ?? new List<long>();
|
||||
if (goodsIds.Count == 0)
|
||||
{
|
||||
throw new AppException("请提供要收藏的商品编号", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (request.GroupId < 0)
|
||||
{
|
||||
throw new AppException("收藏分组无效", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
// 1. 校验分组归属(分组ID > 0 时必须是该用户自己创建的分组)
|
||||
if (request.GroupId > 0)
|
||||
{
|
||||
var group = await _groupRepository.Select
|
||||
.Where(x => x.Id == request.GroupId && x.UserName == userName)
|
||||
.FirstAsync();
|
||||
|
||||
if (group == null)
|
||||
{
|
||||
throw new AppException($"收藏分组不存在: {request.GroupId}", ResultCode.ValidationError);
|
||||
}
|
||||
}
|
||||
|
||||
var groupName = await ResolveGroupNameAsync(userName, request.GroupId);
|
||||
|
||||
// 2. 批量取商品快照(不存在/已删除的商品不收藏,直接跳过)
|
||||
var goodsList = await _goodsRepository.Select
|
||||
.Where(x => goodsIds.Contains(x.GoodsId) && x.Status == 0)
|
||||
.ToListAsync();
|
||||
|
||||
if (goodsList.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var goodsMap = new Dictionary<long, GoodsEntity>();
|
||||
foreach (var goods in goodsList)
|
||||
{
|
||||
goodsMap[goods.GoodsId] = goods;
|
||||
}
|
||||
|
||||
// 3. 批量取已存在的收藏记录
|
||||
var existingList = await _favoriteRepository.Select
|
||||
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
|
||||
.ToListAsync();
|
||||
|
||||
var existingMap = new Dictionary<long, UserGoodsFavoriteEntity>();
|
||||
foreach (var favorite in existingList)
|
||||
{
|
||||
existingMap[favorite.GoodsId] = favorite;
|
||||
}
|
||||
|
||||
// 4. 逐条落库:不存在则新增,已存在且分组不同则修改分组
|
||||
var insertedIds = new List<long>();
|
||||
var changedGroupIds = new List<(long Id, int PreviousGroupId)>();
|
||||
var documents = new List<UserFavoritesSearchDocument>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var goods in goodsMap.Values)
|
||||
{
|
||||
if (existingMap.TryGetValue(goods.GoodsId, out var favorite) == false)
|
||||
{
|
||||
favorite = new UserGoodsFavoriteEntity
|
||||
{
|
||||
UserName = userName,
|
||||
GoodsId = goods.GoodsId,
|
||||
GroupId = request.GroupId,
|
||||
CreatedAt = DateTime.Now,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _favoriteRepository.InsertAsync(favorite);
|
||||
insertedIds.Add(favorite.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 并发收藏同一商品时可能命中唯一键 uk_user_goods,回读已存在记录后按“修改分组”处理
|
||||
favorite = await _favoriteRepository.Select
|
||||
.Where(x => x.UserName == userName && x.GoodsId == goods.GoodsId)
|
||||
.FirstAsync();
|
||||
|
||||
if (favorite == null)
|
||||
{
|
||||
throw new AppException($"收藏商品失败({goods.GoodsId}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (favorite.GroupId != request.GroupId)
|
||||
{
|
||||
changedGroupIds.Add((favorite.Id, favorite.GroupId));
|
||||
await _favoriteRepository.Orm.Update<UserGoodsFavoriteEntity>()
|
||||
.Set(x => x.GroupId, request.GroupId)
|
||||
.Where(x => x.Id == favorite.Id)
|
||||
.ExecuteAffrowsAsync();
|
||||
favorite.GroupId = request.GroupId;
|
||||
}
|
||||
|
||||
documents.Add(BuildDocument(favorite, goods, groupName));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await CompensateWriteFailureAsync(insertedIds, changedGroupIds);
|
||||
throw;
|
||||
}
|
||||
|
||||
// 5. 批量同步写入索引
|
||||
try
|
||||
{
|
||||
await _indexService.IndexDocumentsAsync(documents);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await CompensateWriteFailureAsync(insertedIds, changedGroupIds);
|
||||
throw;
|
||||
}
|
||||
|
||||
return documents.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量取消收藏:先删除数据库记录,再删除索引文档;
|
||||
/// 索引删除失败时回补数据库记录,避免“库里有收藏但搜索不到”。
|
||||
/// </summary>
|
||||
public async Task<int> RemoveFavoritesAsync(RemoveUserFavoritesRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var userName = request.UserName?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var goodsIds = request.GoodsIds?.Where(x => x > 0).Distinct().ToList() ?? new List<long>();
|
||||
if (goodsIds.Count == 0)
|
||||
{
|
||||
throw new AppException("请提供要取消收藏的商品编号", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var favorites = await _favoriteRepository.Select
|
||||
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
|
||||
.ToListAsync();
|
||||
|
||||
if (favorites.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await _favoriteRepository.Orm.Delete<UserGoodsFavoriteEntity>()
|
||||
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _indexService.DeleteDocumentsAsync(
|
||||
favorites.Select(x => UserFavoritesSearchDocument.BuildId(x.UserName, x.GoodsId)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 索引删除失败:回补数据库记录(自增主键会重新生成,检索以“用户名+商品编号”为准,不受影响)
|
||||
_logger.LogError(ex, "删除收藏索引失败,回补数据库收藏记录。UserName={UserName} Count={Count}", userName, favorites.Count);
|
||||
await _favoriteRepository.InsertAsync(favorites);
|
||||
throw;
|
||||
}
|
||||
|
||||
return favorites.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建收藏分组:创建成功返回 true,同一用户同名分组已存在返回 false。
|
||||
/// </summary>
|
||||
public async Task<bool> CreateGroupAsync(CreateUserFavoriteGroupRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var userName = request.UserName?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var groupName = request.GroupName?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(groupName))
|
||||
{
|
||||
throw new AppException("分组名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
if (groupName.Length > 255)
|
||||
{
|
||||
throw new AppException("分组名长度不能超过255个字符", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var existing = await _groupRepository.Select
|
||||
.Where(x => x.UserName == userName && x.GroupName == groupName)
|
||||
.FirstAsync();
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await _groupRepository.InsertAsync(new UserGoodsFavoriteGroupEntity
|
||||
{
|
||||
UserName = userName,
|
||||
GroupName = groupName,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询用户的收藏分组及分组下收藏数量,默认分组(分组ID=0)存在收藏时一并返回。
|
||||
/// </summary>
|
||||
public async Task<List<UserFavoriteGroupDto>> GetGroupsAsync(string userName)
|
||||
{
|
||||
userName = userName?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var groups = await _groupRepository.Select
|
||||
.Where(x => x.UserName == userName)
|
||||
.OrderBy(x => x.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var countList = await _favoriteRepository.Select
|
||||
.Where(x => x.UserName == userName)
|
||||
.GroupBy(x => x.GroupId)
|
||||
.ToListAsync(x => new { GroupId = x.Key, Count = x.Count() });
|
||||
|
||||
var countMap = countList.ToDictionary(x => x.GroupId, x => (long)x.Count);
|
||||
|
||||
var result = new List<UserFavoriteGroupDto>();
|
||||
|
||||
if (countMap.TryGetValue(0, out var defaultCount) && defaultCount > 0)
|
||||
{
|
||||
result.Add(new UserFavoriteGroupDto
|
||||
{
|
||||
Id = 0,
|
||||
UserName = userName,
|
||||
GroupName = DefaultGroupName,
|
||||
FavoriteCount = defaultCount,
|
||||
});
|
||||
}
|
||||
|
||||
result.AddRange(groups.Select(x => new UserFavoriteGroupDto
|
||||
{
|
||||
Id = x.Id,
|
||||
UserName = x.UserName,
|
||||
GroupName = x.GroupName,
|
||||
FavoriteCount = countMap.TryGetValue((int)x.Id, out var count) ? count : 0,
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 GoodsIds 顺序返回每个商品是否已被该用户收藏(商品编号无效的返回 false)。
|
||||
/// 以数据库为准,不依赖索引,索引重建期间结果依然准确。
|
||||
/// </summary>
|
||||
public async Task<List<bool>> GetFavoriteStatusAsync(UserFavoriteStatusRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var userName = request.UserName?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var goodsIds = request.GoodsIds ?? new List<long>();
|
||||
if (goodsIds.Count == 0)
|
||||
{
|
||||
return new List<bool>();
|
||||
}
|
||||
|
||||
var validIds = goodsIds.Where(x => x > 0).Distinct().ToList();
|
||||
var favoritedIds = new HashSet<long>();
|
||||
|
||||
if (validIds.Count > 0)
|
||||
{
|
||||
var favorited = await _favoriteRepository.Select
|
||||
.Where(x => x.UserName == userName && validIds.Contains(x.GoodsId))
|
||||
.ToListAsync(x => x.GoodsId);
|
||||
|
||||
favoritedIds = favorited.ToHashSet();
|
||||
}
|
||||
|
||||
// 结果与入参顺序一一对应,前台可直接按下标渲染
|
||||
return goodsIds.Select(x => x > 0 && favoritedIds.Contains(x)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从收藏索引检索当前用户的收藏商品。
|
||||
/// </summary>
|
||||
public async Task<(List<UserFavoriteItem> List, long Total)> SearchFavoritesAsync(UserFavoritesSearchRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
var userName = request.UserName?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||
}
|
||||
|
||||
request.UserName = userName;
|
||||
|
||||
var searchResult = await _indexService.SearchAsync(request, ParseSortBy(request.SortBy));
|
||||
var list = searchResult.Documents.Select(ToItem).ToList();
|
||||
return (list, searchResult.Total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除所有用户收藏索引并重新创建,然后从 user_goods_favorites 关联 goods 全量回填。
|
||||
/// </summary>
|
||||
public async Task<long> RebuildSearchIndexAsync()
|
||||
{
|
||||
// 1. 删除并重建索引(清空所有用户收藏索引)
|
||||
await _indexService.ClearIndexAsync();
|
||||
|
||||
// 2. 从数据库读取全部收藏记录
|
||||
var favorites = await _favoriteRepository.Select
|
||||
.OrderBy(x => x.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (favorites.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. 关联 goods 补全商品数据(标题/价格/销量/类目/平台/缩略图)
|
||||
var goodsIds = favorites.Select(x => x.GoodsId).Distinct().ToList();
|
||||
var goodsList = await _goodsRepository.Select
|
||||
.Where(x => goodsIds.Contains(x.GoodsId))
|
||||
.ToListAsync();
|
||||
|
||||
var goodsMap = new Dictionary<long, GoodsEntity>();
|
||||
foreach (var goods in goodsList)
|
||||
{
|
||||
goodsMap[goods.GoodsId] = goods;
|
||||
}
|
||||
|
||||
// 4. 关联分组表补全分组名
|
||||
var groupIds = favorites.Where(x => x.GroupId > 0).Select(x => (long)x.GroupId).Distinct().ToList();
|
||||
var groupMap = new Dictionary<int, string>();
|
||||
|
||||
if (groupIds.Count > 0)
|
||||
{
|
||||
var groups = await _groupRepository.Select
|
||||
.Where(x => groupIds.Contains(x.Id))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
groupMap[(int)group.Id] = group.GroupName;
|
||||
}
|
||||
}
|
||||
|
||||
var documents = new List<UserFavoritesSearchDocument>();
|
||||
var missingGoodsCount = 0;
|
||||
|
||||
foreach (var favorite in favorites)
|
||||
{
|
||||
if (goodsMap.TryGetValue(favorite.GoodsId, out var goods) == false)
|
||||
{
|
||||
// 商品已被物理删除,跳过该收藏(不写入索引)
|
||||
missingGoodsCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var groupName = favorite.GroupId <= 0
|
||||
? DefaultGroupName
|
||||
: (groupMap.TryGetValue(favorite.GroupId, out var name) ? name : string.Empty);
|
||||
|
||||
documents.Add(BuildDocument(favorite, goods, groupName));
|
||||
}
|
||||
|
||||
// 5. 分批写入索引
|
||||
for (var offset = 0; offset < documents.Count; offset += RebuildBatchSize)
|
||||
{
|
||||
await _indexService.IndexDocumentsAsync(documents.Skip(offset).Take(RebuildBatchSize));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"用户收藏索引重建完成:收藏 {FavoriteCount} 条,写入索引 {IndexedCount} 条,商品缺失跳过 {MissingCount} 条。",
|
||||
favorites.Count, documents.Count, missingGoodsCount);
|
||||
|
||||
return documents.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收藏落库成功但索引写入失败时的补偿:撤销本次新增的收藏记录、还原被修改的收藏分组。
|
||||
/// </summary>
|
||||
private async Task CompensateWriteFailureAsync(List<long> insertedIds, List<(long Id, int PreviousGroupId)> changedGroupIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (insertedIds.Count > 0)
|
||||
{
|
||||
await _favoriteRepository.Orm.Delete<UserGoodsFavoriteEntity>()
|
||||
.Where(x => insertedIds.Contains(x.Id))
|
||||
.ExecuteAffrowsAsync();
|
||||
}
|
||||
|
||||
foreach (var (id, previousGroupId) in changedGroupIds)
|
||||
{
|
||||
await _favoriteRepository.Orm.Update<UserGoodsFavoriteEntity>()
|
||||
.Set(x => x.GroupId, previousGroupId)
|
||||
.Where(x => x.Id == id)
|
||||
.ExecuteAffrowsAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"回滚用户收藏记录失败。Inserted={InsertedCount} GroupChanged={GroupChangedCount}",
|
||||
insertedIds.Count, changedGroupIds.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取分组名:分组ID <= 0 返回默认分组名,分组不存在返回空串。
|
||||
/// </summary>
|
||||
private async Task<string> ResolveGroupNameAsync(string userName, int groupId)
|
||||
{
|
||||
if (groupId <= 0)
|
||||
{
|
||||
return DefaultGroupName;
|
||||
}
|
||||
|
||||
var group = await _groupRepository.Select
|
||||
.Where(x => x.Id == groupId && x.UserName == userName)
|
||||
.FirstAsync();
|
||||
|
||||
return group?.GroupName ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 组装收藏索引文档:收藏记录 + 商品快照 + 分组名。
|
||||
/// </summary>
|
||||
private static UserFavoritesSearchDocument BuildDocument(UserGoodsFavoriteEntity favorite, GoodsEntity goods, string groupName)
|
||||
{
|
||||
return new UserFavoritesSearchDocument
|
||||
{
|
||||
Id = UserFavoritesSearchDocument.BuildId(favorite.UserName, favorite.GoodsId),
|
||||
FavId = favorite.Id,
|
||||
UserName = favorite.UserName,
|
||||
GroupId = favorite.GroupId,
|
||||
GroupName = groupName,
|
||||
GoodsId = goods.GoodsId,
|
||||
Platform = goods.Platform,
|
||||
Title = goods.Title ?? string.Empty,
|
||||
ThumbUrl = goods.ThumbUrl,
|
||||
CatId1 = goods.CatId1,
|
||||
CatId2 = goods.CatId2,
|
||||
CatId3 = goods.CatId3,
|
||||
CatId4 = goods.CatId4,
|
||||
MinPrice = goods.MinPrice,
|
||||
SoldQuantity = goods.SoldQuantity,
|
||||
CreatedAt = favorite.CreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private static UserFavoriteItem ToItem(UserFavoritesSearchDocument document)
|
||||
{
|
||||
return new UserFavoriteItem
|
||||
{
|
||||
Id = document.FavId,
|
||||
UserName = document.UserName,
|
||||
GroupId = document.GroupId,
|
||||
GroupName = document.GroupName,
|
||||
GoodsId = document.GoodsId,
|
||||
Platform = document.Platform,
|
||||
Title = document.Title,
|
||||
ThumbUrl = document.ThumbUrl,
|
||||
CatId1 = document.CatId1,
|
||||
CatId2 = document.CatId2,
|
||||
CatId3 = document.CatId3,
|
||||
CatId4 = document.CatId4,
|
||||
MinPrice = document.MinPrice,
|
||||
SoldQuantity = document.SoldQuantity,
|
||||
CreatedAt = document.CreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析排序方式,非法值一律按“收藏时间倒序”处理。
|
||||
/// </summary>
|
||||
private static UserFavoritesSortBy ParseSortBy(string? sortBy)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sortBy))
|
||||
{
|
||||
return UserFavoritesSortBy.CreatedTimeDesc;
|
||||
}
|
||||
|
||||
return Enum.TryParse<UserFavoritesSortBy>(sortBy, ignoreCase: true, out var value)
|
||||
? value
|
||||
: UserFavoritesSortBy.CreatedTimeDesc;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
"CloudProductConnection": "Server=192.168.0.200;Port=3306;Database=goods_center;Uid=root;Pwd=123456;Allow User Variables=true",
|
||||
"Search": {
|
||||
"Url": "http://192.168.0.200:9200",
|
||||
"IndexName": "goods_search"
|
||||
"IndexName": "goods_search",
|
||||
"FavoritesIndexName": "user_goods_favorites_search"
|
||||
},
|
||||
"RawData": {
|
||||
"Path": "E:\\GoodsRawData"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
autoReload="true"
|
||||
throwExceptions="false"
|
||||
internalLogLevel="Info"
|
||||
internalLogFile="c:\temp\nlog-internal.log">
|
||||
|
||||
<!-- 启用.net core的核心布局渲染器(传统ASP.NET使用NLog.Web) -->
|
||||
<extensions>
|
||||
<!-- 如果是ASP.NET Core项目使用这个 -->
|
||||
<add assembly="NLog.Web.AspNetCore" />
|
||||
</extensions>
|
||||
|
||||
<!-- 写入日志的目标配置 -->
|
||||
<targets>
|
||||
<!-- 致命错误 - 单独文件 -->
|
||||
<target xsi:type="File" name="fatal"
|
||||
fileName="logs/${date:format=yyyyMM}/Fatal/${shortdate}.log"
|
||||
layout="${longdate}|${level:uppercase=true}|${logger}|${callsite}|${message}|${exception:format=tostring}"
|
||||
archiveEvery="Day"
|
||||
maxArchiveFiles="30"
|
||||
concurrentWrites="true" />
|
||||
|
||||
<!-- 错误日志 -->
|
||||
<target xsi:type="File" name="error"
|
||||
fileName="logs/${date:format=yyyyMM}/Error/${shortdate}.log"
|
||||
layout="${longdate}|${level:uppercase=true}|${logger}|${callsite}|${message}|${exception:format=tostring}"
|
||||
archiveEvery="Day"
|
||||
maxArchiveFiles="30"
|
||||
concurrentWrites="true" />
|
||||
|
||||
<!-- 警告日志 -->
|
||||
<target xsi:type="File" name="warn"
|
||||
fileName="logs/${date:format=yyyyMM}/Warn/${shortdate}.log"
|
||||
layout="${longdate}|${level:uppercase=true}|${logger}|${callsite}|${message}|${exception:format=tostring}"
|
||||
archiveEvery="Day"
|
||||
maxArchiveFiles="30"
|
||||
concurrentWrites="true" />
|
||||
|
||||
<!-- 信息日志 -->
|
||||
<target xsi:type="File" name="info"
|
||||
fileName="logs/${date:format=yyyyMM}/Info/${shortdate}.log"
|
||||
layout="${longdate}|${level:uppercase=true}|${logger}|${callsite}|${message}|${exception:format=tostring}"
|
||||
archiveEvery="Day"
|
||||
maxArchiveFiles="30"
|
||||
concurrentWrites="true" />
|
||||
|
||||
<!-- 调试日志 -->
|
||||
<target xsi:type="File" name="debug"
|
||||
fileName="logs/${date:format=yyyyMM}/Debug/${shortdate}.log"
|
||||
layout="${longdate}|${level:uppercase=true}|${logger}|${callsite}|${message}|${exception:format=tostring}"
|
||||
archiveEvery="Day"
|
||||
maxArchiveFiles="7"
|
||||
concurrentWrites="true" />
|
||||
|
||||
<!-- 控制台输出(调试用) -->
|
||||
<target xsi:type="Console" name="console"
|
||||
layout="${longdate}|${level:uppercase=true}|${logger}|${message}" />
|
||||
</targets>
|
||||
|
||||
<!-- 映射规则 -->
|
||||
<rules>
|
||||
<!-- 忽略 Hangfire 的 Debug 日志 -->
|
||||
<logger name="Hangfire.*" maxlevel="Debug" writeTo="BlackHole" final="true" />
|
||||
|
||||
<!-- 规则1: 调试级别(Trace, Debug)写入debug文件 -->
|
||||
<logger name="*" minlevel="Trace" maxlevel="Debug" writeTo="debug,console" />
|
||||
|
||||
<!-- 规则2: 跳过微软框架的Info级别以下日志(减少噪音) -->
|
||||
<logger name="Microsoft.*" maxlevel="Info" final="true" />
|
||||
|
||||
<!-- 规则3: 信息级别(Info, Warn)写入info和warn文件 -->
|
||||
<logger name="*" level="Info" writeTo="info" />
|
||||
<logger name="*" level="Warn" writeTo="warn" />
|
||||
<!-- 或者合并为一行:<logger name="*" minlevel="Info" maxlevel="Warn" writeTo="info,warn" /> -->
|
||||
|
||||
<!-- 规则4: 错误级别(Error, Fatal)写入error和fatal文件 -->
|
||||
<logger name="*" level="Error" writeTo="error" />
|
||||
<logger name="*" level="Fatal" writeTo="fatal,error" />
|
||||
|
||||
<!-- 如果需要,也可以将所有级别都记录到同一个文件 -->
|
||||
<!-- <logger name="*" minlevel="Trace" writeTo="all" /> -->
|
||||
</rules>
|
||||
</nlog>
|
||||
@@ -1,4 +1,5 @@
|
||||
using Asp.Versioning;
|
||||
using MAX.Core.Logger;
|
||||
using MAX.Framework.DependencyInjection;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
@@ -36,6 +37,7 @@ public partial class Program
|
||||
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseStore();
|
||||
// 初始化 KJD 迁移的静态提供者(MysqlUtils 连接提供 / 业务配置读取)
|
||||
//StoreHub.KJDWebService.Commons.AdoProvider.SetServiceProvider(app.Services);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "scalar/v1",
|
||||
"applicationUrl": "http://localhost:9000",
|
||||
"applicationUrl": "http://localhost:14312",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "scalar/v1",
|
||||
"applicationUrl": "https://localhost:7241;http://localhost:9000",
|
||||
"applicationUrl": "https://localhost:7241;http://localhost:14312",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SatelliteResourceLanguages>zh-Hans;zh-Hant;zh;en</SatelliteResourceLanguages>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;",
|
||||
"DefaultConnection": "Server=11192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;",
|
||||
"KJDConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;",
|
||||
"ConnKJD": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;"
|
||||
//"KJDConnection": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-hl51pkfr.sql.tencentcdb.com';Port=27660;User Id='kjd';Password='Temukjd0625';Charset=utf8mb4;",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.13",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user