From 08757d15e44e2538aae33d1c0ae18b906119d21c Mon Sep 17 00:00:00 2001 From: chfei Date: Wed, 14 Oct 2026 17:33:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20=E4=BA=91=E4=BB=93?= =?UTF-8?q?=E5=95=86=E5=93=81=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ServiceCollectionExtensions.cs | 3 + Core/MAX.Framework/Logger/NLogger.cs | 2 +- Core/MAX.Framework/MAX.Framework.csproj | 3 + Modules/CloudProduct/CloudProductModule.cs | 3 +- .../Controllers/GoodsController.cs | 4 +- .../Controllers/UserFavoritesController.cs | 269 ++++++++ .../Dtos/AddUserFavoriteRequest.cs | 23 + .../Dtos/CreateUserFavoriteGroupRequest.cs | 18 + .../Dtos/RemoveUserFavoritesRequest.cs | 18 + .../CloudProduct/Dtos/UserFavoriteGroupDto.cs | 20 + Modules/CloudProduct/Dtos/UserFavoriteItem.cs | 53 ++ .../Dtos/UserFavoriteStatusRequest.cs | 18 + .../Dtos/UserFavoritesSearchDocument.cs | 87 +++ .../Dtos/UserFavoritesSearchRequest.cs | 88 +++ .../Entities/UserGoodsFavoriteEntity.cs | 43 ++ .../Entities/UserGoodsFavoriteGroupEntity.cs | 31 + .../CloudProduct/Enums/UserFavoritesSortBy.cs | 23 + .../UserGoodsFavoriteGroupRepository.cs | 17 + .../UserGoodsFavoriteRepository.cs | 17 + .../Services/UserFavoritesIndexService.cs | 420 ++++++++++++ .../Services/UserFavoritesService.cs | 603 ++++++++++++++++++ Modules/CloudProduct/appsettings.json | 3 +- Store.API/Nlog.config | 85 +++ Store.API/Program.cs | 2 + Store.API/Properties/launchSettings.json | 4 +- Store.API/Store.Api.csproj | 2 + Store.API/appsettings.json | 2 +- Store.API/dotnet-tools.json | 13 + 28 files changed, 1866 insertions(+), 8 deletions(-) create mode 100644 Modules/CloudProduct/Controllers/UserFavoritesController.cs create mode 100644 Modules/CloudProduct/Dtos/AddUserFavoriteRequest.cs create mode 100644 Modules/CloudProduct/Dtos/CreateUserFavoriteGroupRequest.cs create mode 100644 Modules/CloudProduct/Dtos/RemoveUserFavoritesRequest.cs create mode 100644 Modules/CloudProduct/Dtos/UserFavoriteGroupDto.cs create mode 100644 Modules/CloudProduct/Dtos/UserFavoriteItem.cs create mode 100644 Modules/CloudProduct/Dtos/UserFavoriteStatusRequest.cs create mode 100644 Modules/CloudProduct/Dtos/UserFavoritesSearchDocument.cs create mode 100644 Modules/CloudProduct/Dtos/UserFavoritesSearchRequest.cs create mode 100644 Modules/CloudProduct/Entities/UserGoodsFavoriteEntity.cs create mode 100644 Modules/CloudProduct/Entities/UserGoodsFavoriteGroupEntity.cs create mode 100644 Modules/CloudProduct/Enums/UserFavoritesSortBy.cs create mode 100644 Modules/CloudProduct/Repositorys/UserGoodsFavoriteGroupRepository.cs create mode 100644 Modules/CloudProduct/Repositorys/UserGoodsFavoriteRepository.cs create mode 100644 Modules/CloudProduct/Services/UserFavoritesIndexService.cs create mode 100644 Modules/CloudProduct/Services/UserFavoritesService.cs create mode 100644 Store.API/Nlog.config create mode 100644 Store.API/dotnet-tools.json diff --git a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs index 4087f91..e5262de 100644 --- a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs +++ b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs @@ -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 /// public static IServiceCollection AddStore(this IHostApplicationBuilder host) { + //host.Services.AddSingleton(NLogger.Instance);//注册log + IEnumerable assemblies = GetApplicationAssemblies(); // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象 Dictionary instances = []; diff --git a/Core/MAX.Framework/Logger/NLogger.cs b/Core/MAX.Framework/Logger/NLogger.cs index f1d7c6f..6b287d9 100644 --- a/Core/MAX.Framework/Logger/NLogger.cs +++ b/Core/MAX.Framework/Logger/NLogger.cs @@ -1,4 +1,4 @@ -//using MAX.Core.Infrastructure; +//using MAX.Core.Interfaces; //using MAX.Core.Logger; //using NLog; //using System; diff --git a/Core/MAX.Framework/MAX.Framework.csproj b/Core/MAX.Framework/MAX.Framework.csproj index 9c4469e..ccff2f7 100644 --- a/Core/MAX.Framework/MAX.Framework.csproj +++ b/Core/MAX.Framework/MAX.Framework.csproj @@ -18,6 +18,9 @@ + + + diff --git a/Modules/CloudProduct/CloudProductModule.cs b/Modules/CloudProduct/CloudProductModule.cs index f0759e7..24ead13 100644 --- a/Modules/CloudProduct/CloudProductModule.cs +++ b/Modules/CloudProduct/CloudProductModule.cs @@ -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("CloudProductConnection") ?? throw new InvalidOperationException("缺少配置 ConnectionStrings:DefaultConnection,CloudProduct 模块的 Hangfire 无法启动。"); // AllowUserVariables=true:Hangfire.MySqlStorage 的建表/查询语句依赖用户变量 diff --git a/Modules/CloudProduct/Controllers/GoodsController.cs b/Modules/CloudProduct/Controllers/GoodsController.cs index f7a2758..db57ec9 100644 --- a/Modules/CloudProduct/Controllers/GoodsController.cs +++ b/Modules/CloudProduct/Controllers/GoodsController.cs @@ -314,7 +314,7 @@ namespace Store.Modules.CloudProduct.Controllers /// /// 读取指定商品详情:解压缩原始数据并返回 JSON。 /// - [HttpGet] + [HttpPost] public async Task GetGoodsDetail([FromQuery] long goodsId) { var raw = await GetRawDataOrThrowAsync(goodsId); @@ -325,7 +325,7 @@ namespace Store.Modules.CloudProduct.Controllers /// /// 读取指定商品的原始数据(解压缩后原样返回)。 /// - [HttpGet] + [HttpPost] public async Task GetGoodsRawData([FromQuery] long goodsId) { return await GetRawDataOrThrowAsync(goodsId); diff --git a/Modules/CloudProduct/Controllers/UserFavoritesController.cs b/Modules/CloudProduct/Controllers/UserFavoritesController.cs new file mode 100644 index 0000000..5023a8e --- /dev/null +++ b/Modules/CloudProduct/Controllers/UserFavoritesController.cs @@ -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 +{ + /// + /// 用户收藏商品:收藏/取消收藏(数据库 + 收藏索引同步)、收藏分组、收藏状态查询、收藏检索与索引重建。 + /// 请求中的 userName 允许为空,为空时取当前登录用户。 + /// + [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; + } + + /// + /// 批量收藏商品:写入 user_goods_favorites 并同步写入收藏索引,返回本次成功收藏的商品数量。 + /// 商品已收藏时视为修改收藏分组;商品不存在/已删除的编号会被跳过。 + /// + [HttpPost] + public async Task 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); + } + + /// + /// 批量取消收藏:删除 user_goods_favorites 记录并同步删除收藏索引文档,返回实际取消的条数。 + /// + [HttpPost] + public async Task 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); + } + + /// + /// 创建收藏分组:创建成功返回 true,同名分组已存在返回 false。 + /// + [HttpPost] + public async Task 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); + } + + /// + /// 查询用户的收藏分组(含默认分组)及分组下收藏数量。 + /// + [HttpGet] + public async Task> GetGroups(string? userName = null) + { + var resolved = ResolveUserName(userName); + if (string.IsNullOrWhiteSpace(resolved)) + { + throw new AppException("用户名不能为空", ResultCode.ValidationError); + } + + return await _favoritesService.GetGroupsAsync(resolved); + } + + /// + /// 按 GoodsIds 顺序返回每个商品是否已被收藏(如 [true,false,...]),前台可按顺序直接渲染。 + /// + [HttpPost] + public async Task> 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); + } + + /// + /// 检索用户收藏的商品(标题、销量、价格、类目、分组)。 + /// + [HttpPost] + public async Task> 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 + { + List = result.List, + Total = result.Total, + PageSize = request.PageSize, + PageIndex = request.PageIndex, + }; + } + + /// + /// 移除所有用户收藏索引并重新创建,随后按 user_goods_favorites 关联 goods 全量回填。 + /// + [HttpPost] + public async Task RebuildSearchIndex() + { + return await _favoritesService.RebuildSearchIndexAsync(); + } + + /// + /// 取用户名:显式传入优先,否则取当前登录用户。 + /// + private string ResolveUserName(string? userName) + { + return string.IsNullOrWhiteSpace(userName) + ? (_currentUser.UserName ?? string.Empty) + : userName.Trim(); + } + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Dtos/AddUserFavoriteRequest.cs b/Modules/CloudProduct/Dtos/AddUserFavoriteRequest.cs new file mode 100644 index 0000000..87a1ba1 --- /dev/null +++ b/Modules/CloudProduct/Dtos/AddUserFavoriteRequest.cs @@ -0,0 +1,23 @@ +namespace Store.Modules.CloudProduct.Dtos +{ + /// + /// 收藏商品请求(支持多选批量收藏) + /// + public class AddUserFavoriteRequest + { + /// + /// 用户名(为空时取当前登录用户) + /// + public string UserName { get; set; } = string.Empty; + + /// + /// 商品编号集合(可多选) + /// + public List GoodsIds { get; set; } = new(); + + /// + /// 收藏分组ID(默认0,表示默认分组;大于0时必须存在且属于该用户) + /// + public int GroupId { get; set; } + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Dtos/CreateUserFavoriteGroupRequest.cs b/Modules/CloudProduct/Dtos/CreateUserFavoriteGroupRequest.cs new file mode 100644 index 0000000..c0dc188 --- /dev/null +++ b/Modules/CloudProduct/Dtos/CreateUserFavoriteGroupRequest.cs @@ -0,0 +1,18 @@ +namespace Store.Modules.CloudProduct.Dtos +{ + /// + /// 创建收藏分组请求 + /// + public class CreateUserFavoriteGroupRequest + { + /// + /// 用户名(为空时取当前登录用户) + /// + public string UserName { get; set; } = string.Empty; + + /// + /// 分组名 + /// + public string GroupName { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Dtos/RemoveUserFavoritesRequest.cs b/Modules/CloudProduct/Dtos/RemoveUserFavoritesRequest.cs new file mode 100644 index 0000000..10b02e1 --- /dev/null +++ b/Modules/CloudProduct/Dtos/RemoveUserFavoritesRequest.cs @@ -0,0 +1,18 @@ +namespace Store.Modules.CloudProduct.Dtos +{ + /// + /// 取消收藏(批量)请求 + /// + public class RemoveUserFavoritesRequest + { + /// + /// 用户名(为空时取当前登录用户) + /// + public string UserName { get; set; } = string.Empty; + + /// + /// 要取消收藏的商品编号集合 + /// + public List GoodsIds { get; set; } = new(); + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Dtos/UserFavoriteGroupDto.cs b/Modules/CloudProduct/Dtos/UserFavoriteGroupDto.cs new file mode 100644 index 0000000..b45ee6b --- /dev/null +++ b/Modules/CloudProduct/Dtos/UserFavoriteGroupDto.cs @@ -0,0 +1,20 @@ +namespace Store.Modules.CloudProduct.Dtos +{ + /// + /// 用户收藏分组 + /// + public class UserFavoriteGroupDto + { + /// 分组ID + public long Id { get; set; } + + /// 用户名 + public string UserName { get; set; } = string.Empty; + + /// 分组名 + public string GroupName { get; set; } = string.Empty; + + /// 该分组下的收藏数量 + public long FavoriteCount { get; set; } + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Dtos/UserFavoriteItem.cs b/Modules/CloudProduct/Dtos/UserFavoriteItem.cs new file mode 100644 index 0000000..4778393 --- /dev/null +++ b/Modules/CloudProduct/Dtos/UserFavoriteItem.cs @@ -0,0 +1,53 @@ +namespace Store.Modules.CloudProduct.Dtos +{ + /// + /// 用户收藏商品(索引文档回显模型) + /// + public class UserFavoriteItem + { + /// 收藏记录自增主键 + public long Id { get; set; } + + /// 用户名 + public string UserName { get; set; } = string.Empty; + + /// 收藏分组ID(0 表示默认分组) + public int GroupId { get; set; } + + /// 收藏分组名 + public string GroupName { get; set; } = string.Empty; + + /// 商品编号 + public long GoodsId { get; set; } + + /// 平台类型 + public byte Platform { get; set; } + + /// 商品标题 + public string Title { get; set; } = string.Empty; + + /// 缩略图 + public string? ThumbUrl { get; set; } + + /// 一级类目ID + public int? CatId1 { get; set; } + + /// 二级类目ID + public int? CatId2 { get; set; } + + /// 三级类目ID + public int? CatId3 { get; set; } + + /// 四级类目ID + public int? CatId4 { get; set; } + + /// 最低价快照(分) + public long? MinPrice { get; set; } + + /// 销量 + public int SoldQuantity { get; set; } + + /// 收藏时间 + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Dtos/UserFavoriteStatusRequest.cs b/Modules/CloudProduct/Dtos/UserFavoriteStatusRequest.cs new file mode 100644 index 0000000..787bb7a --- /dev/null +++ b/Modules/CloudProduct/Dtos/UserFavoriteStatusRequest.cs @@ -0,0 +1,18 @@ +namespace Store.Modules.CloudProduct.Dtos +{ + /// + /// 查询商品是否已被收藏请求:按 GoodsIds 顺序返回是否已收藏 + /// + public class UserFavoriteStatusRequest + { + /// + /// 用户名(为空时取当前登录用户) + /// + public string UserName { get; set; } = string.Empty; + + /// + /// 商品编号集合 + /// + public List GoodsIds { get; set; } = new(); + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Dtos/UserFavoritesSearchDocument.cs b/Modules/CloudProduct/Dtos/UserFavoritesSearchDocument.cs new file mode 100644 index 0000000..096623c --- /dev/null +++ b/Modules/CloudProduct/Dtos/UserFavoritesSearchDocument.cs @@ -0,0 +1,87 @@ +using OpenSearch.Client; + +namespace Store.Modules.CloudProduct.Dtos +{ + /// + /// 用户收藏商品搜索文档:OpenSearch 索引中的收藏模型。 + /// 一条收藏记录 = 用户 + 商品快照(标题/价格/销量/类目)+ 收藏分组, + /// 文档 Id 固定为 "{user_name}:{goods_id}"(表 uk_user_goods 保证唯一),便于收藏/取消收藏时幂等覆盖与删除。 + /// + public class UserFavoritesSearchDocument + { + /// + /// 文档主键(同时作为 OpenSearch 的 _id):{user_name}:{goods_id} + /// + [Keyword(Name = "id")] + public string Id { get; set; } = string.Empty; + + /// + /// 收藏记录自增主键(user_goods_favorites.id) + /// + [Number(NumberType.Long, Name = "fav_id")] + public long FavId { get; set; } + + /// 用户名(收藏归属人,检索时强制过滤) + [Keyword(Name = "user_name")] + public string UserName { get; set; } = string.Empty; + + /// 收藏分组ID(0 表示默认分组) + [Number(NumberType.Integer, Name = "group_id")] + public int GroupId { get; set; } + + /// 收藏分组名(分组ID为0时为默认分组名) + [Keyword(Name = "group_name")] + public string GroupName { get; set; } = string.Empty; + + /// 商品编号 + [Number(NumberType.Long, Name = "goods_id")] + public long GoodsId { get; set; } + + /// 平台类型(1 PDD、2 WX) + [Number(NumberType.Byte, Name = "platform")] + public byte Platform { get; set; } + + /// 商品标题,用于分词搜索 + [Text(Name = "title", Analyzer = "ik_max_word", SearchAnalyzer = "ik_smart")] + public string Title { get; set; } = string.Empty; + + /// 缩略图 + [Text(Name = "thumb_url")] + public string? ThumbUrl { get; set; } + + /// 一级类目ID + [Number(NumberType.Integer, Name = "cat_id_1")] + public int? CatId1 { get; set; } + + /// 二级类目ID + [Number(NumberType.Integer, Name = "cat_id_2")] + public int? CatId2 { get; set; } + + /// 三级类目ID + [Number(NumberType.Integer, Name = "cat_id_3")] + public int? CatId3 { get; set; } + + /// 四级类目ID + [Number(NumberType.Integer, Name = "cat_id_4")] + public int? CatId4 { get; set; } + + /// 最低价快照(分),用于价格区间过滤和按价格排序 + [Number(NumberType.Long, Name = "min_price")] + public long? MinPrice { get; set; } + + /// 销量,用于按销量排序 + [Number(NumberType.Integer, Name = "sold_quantity")] + public int SoldQuantity { get; set; } + + /// 收藏时间,默认按此字段倒序 + [Date(Name = "created_at")] + public DateTime CreatedAt { get; set; } + + /// + /// 生成文档主键:{user_name}:{goods_id} + /// + 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}"; + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Dtos/UserFavoritesSearchRequest.cs b/Modules/CloudProduct/Dtos/UserFavoritesSearchRequest.cs new file mode 100644 index 0000000..baf9ec6 --- /dev/null +++ b/Modules/CloudProduct/Dtos/UserFavoritesSearchRequest.cs @@ -0,0 +1,88 @@ +namespace Store.Modules.CloudProduct.Dtos +{ + /// + /// 用户收藏商品搜索请求(仅检索当前用户自己的收藏) + /// + public class UserFavoritesSearchRequest + { + /// + /// 用户名(为空时取当前登录用户) + /// + public string UserName { get; set; } = string.Empty; + + /// + /// 标题关键字(分词模糊搜索) + /// + public string? Title { get; set; } + + /// + /// 收藏分组ID(0 表示默认分组) + /// + public int? GroupId { get; set; } + + /// + /// 收藏分组名(与分组ID二选一,同时传入时以分组ID为准) + /// + public string? GroupName { get; set; } + + /// + /// 最低价格(分) + /// + public long? MinPrice { get; set; } + + /// + /// 最高价格(分) + /// + public long? MaxPrice { get; set; } + + /// + /// 一级类目ID + /// + public int? CatId1 { get; set; } + + /// + /// 二级类目ID + /// + public int? CatId2 { get; set; } + + /// + /// 三级类目ID + /// + public int? CatId3 { get; set; } + + /// + /// 四级类目ID + /// + public int? CatId4 { get; set; } + + /// + /// 最低销量 + /// + public int? MinSoldQuantity { get; set; } + + /// + /// 最高销量 + /// + public int? MaxSoldQuantity { get; set; } + + /// + /// 平台类型 + /// + public byte? Platform { get; set; } + + /// + /// 页码(默认1) + /// + public int PageIndex { get; set; } = 1; + + /// + /// 每页数量(默认20) + /// + public int PageSize { get; set; } = 20; + + /// + /// 排序方式:CreatedTimeDesc(默认,收藏时间倒序)/ CreatedTimeAsc / PriceAsc / PriceDesc / SoldDesc + /// + public string SortBy { get; set; } = "CreatedTimeDesc"; + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Entities/UserGoodsFavoriteEntity.cs b/Modules/CloudProduct/Entities/UserGoodsFavoriteEntity.cs new file mode 100644 index 0000000..d571df8 --- /dev/null +++ b/Modules/CloudProduct/Entities/UserGoodsFavoriteEntity.cs @@ -0,0 +1,43 @@ +using FreeSql.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Store.Modules.CloudProduct.Entities +{ + /// + /// 用户收藏商品表实体(对应数据库表 user_goods_favorites) + /// + [Table(Name = "user_goods_favorites")] + public class UserGoodsFavoriteEntity + { + /// + /// 自增主键 + /// + [Column(Name = "id", IsPrimary = true, IsIdentity = true)] + [JsonIgnore] + public long Id { get; set; } + + /// + /// 用户名(同一用户不能重复收藏同一商品) + /// + [Column(Name = "user_name", IsNullable = false, StringLength = 50)] + public string UserName { get; set; } = string.Empty; + + /// + /// 收藏分组ID(0 表示默认分组),对应 user_goods_favorites_group.id + /// + [Column(Name = "group_id", IsNullable = false)] + public int GroupId { get; set; } + + /// + /// 商品编号,对应 goods.goods_id + /// + [Column(Name = "goods_id", IsNullable = false)] + public long GoodsId { get; set; } + + /// + /// 收藏时间 + /// + [Column(Name = "created_at", IsNullable = false)] + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Entities/UserGoodsFavoriteGroupEntity.cs b/Modules/CloudProduct/Entities/UserGoodsFavoriteGroupEntity.cs new file mode 100644 index 0000000..437b0b3 --- /dev/null +++ b/Modules/CloudProduct/Entities/UserGoodsFavoriteGroupEntity.cs @@ -0,0 +1,31 @@ +using FreeSql.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Store.Modules.CloudProduct.Entities +{ + /// + /// 用户收藏分组表实体(对应数据库表 user_goods_favorites_group) + /// + [Table(Name = "user_goods_favorites_group")] + public class UserGoodsFavoriteGroupEntity + { + /// + /// 自增主键,即收藏表 user_goods_favorites.group_id 的取值 + /// + [Column(Name = "id", IsPrimary = true, IsIdentity = true)] + [JsonIgnore] + public long Id { get; set; } + + /// + /// 用户名 + /// + [Column(Name = "user_name", IsNullable = false, StringLength = 50)] + public string UserName { get; set; } = string.Empty; + + /// + /// 分组名 + /// + [Column(Name = "group_name", IsNullable = false, StringLength = 255)] + public string GroupName { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Enums/UserFavoritesSortBy.cs b/Modules/CloudProduct/Enums/UserFavoritesSortBy.cs new file mode 100644 index 0000000..b09f18d --- /dev/null +++ b/Modules/CloudProduct/Enums/UserFavoritesSortBy.cs @@ -0,0 +1,23 @@ +namespace Store.Modules.CloudProduct.Enums +{ + /// + /// 用户收藏商品搜索排序方式 + /// + public enum UserFavoritesSortBy + { + /// 按收藏时间倒序(默认) + CreatedTimeDesc = 0, + + /// 按收藏时间正序 + CreatedTimeAsc = 1, + + /// 价格从低到高 + PriceAsc = 2, + + /// 价格从高到低 + PriceDesc = 3, + + /// 销量从高到低 + SoldDesc = 4, + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Repositorys/UserGoodsFavoriteGroupRepository.cs b/Modules/CloudProduct/Repositorys/UserGoodsFavoriteGroupRepository.cs new file mode 100644 index 0000000..066695f --- /dev/null +++ b/Modules/CloudProduct/Repositorys/UserGoodsFavoriteGroupRepository.cs @@ -0,0 +1,17 @@ +using FreeSql; +using MAX.Core.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Store.Modules.CloudProduct.Entities; + +namespace Store.Modules.CloudProduct.Repositorys +{ + /// + /// 用户收藏分组仓储(对应 user_goods_favorites_group 表) + /// + public class UserGoodsFavoriteGroupRepository : BaseRepository, IScopedDependency + { + public UserGoodsFavoriteGroupRepository([FromKeyedServices("CloudProductConnection")] IFreeSql fsql) : base(fsql) + { + } + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Repositorys/UserGoodsFavoriteRepository.cs b/Modules/CloudProduct/Repositorys/UserGoodsFavoriteRepository.cs new file mode 100644 index 0000000..f6df5b6 --- /dev/null +++ b/Modules/CloudProduct/Repositorys/UserGoodsFavoriteRepository.cs @@ -0,0 +1,17 @@ +using FreeSql; +using MAX.Core.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Store.Modules.CloudProduct.Entities; + +namespace Store.Modules.CloudProduct.Repositorys +{ + /// + /// 用户收藏商品仓储(对应 user_goods_favorites 表) + /// + public class UserGoodsFavoriteRepository : BaseRepository, IScopedDependency + { + public UserGoodsFavoriteRepository([FromKeyedServices("CloudProductConnection")] IFreeSql fsql) : base(fsql) + { + } + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Services/UserFavoritesIndexService.cs b/Modules/CloudProduct/Services/UserFavoritesIndexService.cs new file mode 100644 index 0000000..736415b --- /dev/null +++ b/Modules/CloudProduct/Services/UserFavoritesIndexService.cs @@ -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 +{ + /// + /// 用户收藏商品索引服务:负责收藏索引(Search:FavoritesIndexName)的 + /// 创建、写入、删除、重建与检索,索引文档由 user_goods_favorites 关联 goods 补全而来。 + /// + public interface IUserFavoritesIndexService : ISingletonDependency + { + /// 检查索引是否存在 + Task IndexExistsAsync(); + + /// 检查索引映射(分析器)是否符合当前服务要求 + Task IsIndexMappingHealthyAsync(); + + /// 创建索引(含 IK 分词映射;已存在且映射正常时跳过) + Task CreateIndexAsync(); + + /// 删除并重建索引(映射统一,重建期间该索引不可查) + Task ClearIndexAsync(); + + /// 批量写入收藏文档(存在则覆盖) + Task IndexDocumentsAsync(IEnumerable documents); + + /// 按文档Id批量删除收藏文档 + Task DeleteDocumentsAsync(IEnumerable documentIds); + + /// 统计索引文档数 + Task CountDocumentsAsync(); + + /// 按用户检索收藏商品(标题/销量/价格/类目/分组) + Task<(long Total, List 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 _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 logger) + { + var config = configuration.GetSection("Search"); + _nodeUrl = config.GetValue("Url") ?? ""; + _indexName = config.GetValue("FavoritesIndexName") ?? ""; + _logger = logger; + _client = CreateClient(); + } + + private IOpenSearchClient CreateClient() + { + var pool = new SingleNodeConnectionPool(new Uri(_nodeUrl)); + var settings = new ConnectionSettings(pool) + .DefaultIndex(_indexName) + .DefaultMappingFor(m => m.IndexName(_indexName)) + .RequestTimeout(QueryTimeout) + .MaxRetryTimeout(QueryTimeout) + .PrettyJson() + .DisableDirectStreaming(); + + return new OpenSearchClient(settings); + } + + /// + /// ============ 1. 索引是否存在 ============ + /// + public async Task IndexExistsAsync() + { + var response = await _client.Indices.ExistsAsync(_indexName, d => d + .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); + return response.Exists; + } + + /// + /// ============ 2. 索引映射是否健康 ============ + /// + public async Task 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; + } + + /// + /// ============ 3. 创建索引 ============ + /// + public async Task 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 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(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)) + ); + } + + /// + /// ============ 4. 删除并重建索引 ============ + /// + public async Task 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); + } + } + + /// + /// ============ 5. 批量写入收藏文档(存在则覆盖) ============ + /// + public async Task IndexDocumentsAsync(IEnumerable 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(); + } + + /// + /// ============ 6. 按文档Id批量删除收藏文档 ============ + /// + public async Task DeleteDocumentsAsync(IEnumerable documentIds) + { + var list = documentIds?.Where(x => string.IsNullOrWhiteSpace(x) == false).Distinct().ToList() ?? new List(); + if (list.Count == 0) return; + + if (await IndexExistsAsync() == false) + { + return; + } + + var response = await _client.BulkAsync(b => + { + foreach (var id in list) + { + b.Delete(d => d.Index(_indexName).Id(id)); + } + b.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)); + return b; + }); + + if (!response.IsValid) + { + throw new AppException("删除收藏索引文档失败: " + response.DebugInformation); + } + + await RefreshIndexAsync(); + } + + /// + /// ============ 7. 统计文档数 ============ + /// + public async Task CountDocumentsAsync() + { + var countResponse = await _client.CountAsync(c => c + .Index(_indexName) + .RequestConfiguration(r => r.RequestTimeout(QueryTimeout)) + ); + if (!countResponse.IsValid) + { + throw new AppException("统计收藏索引失败: " + countResponse.DebugInformation); + } + + return countResponse.Count; + } + + /// + /// ============ 8. 检索用户收藏商品(标题/价格/销量/类目/分组 + 排序 + 分页) ============ + /// + public async Task<(long Total, List 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 searchResponse; + try + { + searchResponse = await _client.SearchAsync(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, 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()); + } + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/Services/UserFavoritesService.cs b/Modules/CloudProduct/Services/UserFavoritesService.cs new file mode 100644 index 0000000..aada308 --- /dev/null +++ b/Modules/CloudProduct/Services/UserFavoritesService.cs @@ -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 +{ + /// + /// 用户收藏商品业务:收藏/取消收藏(数据库 + 收藏索引同步写入)、收藏分组维护、收藏状态查询、收藏检索与索引重建。 + /// + public interface IUserFavoritesService : IScopedDependency + { + /// 批量收藏商品,返回本次成功收藏的商品数量(已收藏的商品视为修改收藏分组) + Task AddFavoritesAsync(AddUserFavoriteRequest request); + + /// 批量取消收藏,返回实际取消的条数 + Task RemoveFavoritesAsync(RemoveUserFavoritesRequest request); + + /// 创建收藏分组:创建成功返回 true,同名分组已存在返回 false + Task CreateGroupAsync(CreateUserFavoriteGroupRequest request); + + /// 查询用户的收藏分组(含默认分组)及分组下收藏数量 + Task> GetGroupsAsync(string userName); + + /// 按 GoodsIds 顺序返回每个商品是否已被该用户收藏 + Task> GetFavoriteStatusAsync(UserFavoriteStatusRequest request); + + /// 检索用户收藏的商品(标题/销量/价格/类目/分组) + Task<(List List, long Total)> SearchFavoritesAsync(UserFavoritesSearchRequest request); + + /// 移除所有用户收藏索引并按数据库数据全量重建,返回重建的文档数 + Task RebuildSearchIndexAsync(); + } + + internal class UserFavoritesService : IUserFavoritesService + { + /// 未归属任何分组的收藏所使用的分组名(对应 group_id = 0) + public const string DefaultGroupName = "默认分组"; + + /// 重建索引时单次 bulk 的文档数,避免一次请求过大 + private const int RebuildBatchSize = 2000; + + private readonly UserGoodsFavoriteRepository _favoriteRepository; + private readonly UserGoodsFavoriteGroupRepository _groupRepository; + private readonly GoodsRepository _goodsRepository; + private readonly IUserFavoritesIndexService _indexService; + private readonly ILogger _logger; + + public UserFavoritesService( + UserGoodsFavoriteRepository favoriteRepository, + UserGoodsFavoriteGroupRepository groupRepository, + GoodsRepository goodsRepository, + IUserFavoritesIndexService indexService, + ILogger logger) + { + _favoriteRepository = favoriteRepository; + _groupRepository = groupRepository; + _goodsRepository = goodsRepository; + _indexService = indexService; + _logger = logger; + } + + /// + /// 批量收藏商品:先落库(user_goods_favorites),再同步写入收藏索引; + /// 商品不存在/已删除的编号会被跳过,返回本次成功收藏的商品数量。 + /// 任意一步失败都会回滚本次已写入的数据库记录,保证“数据库中有收藏 = 索引中可搜索到”。 + /// + public async Task 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(); + 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(); + 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(); + foreach (var favorite in existingList) + { + existingMap[favorite.GoodsId] = favorite; + } + + // 4. 逐条落库:不存在则新增,已存在且分组不同则修改分组 + var insertedIds = new List(); + var changedGroupIds = new List<(long Id, int PreviousGroupId)>(); + var documents = new List(); + + 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() + .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; + } + + /// + /// 批量取消收藏:先删除数据库记录,再删除索引文档; + /// 索引删除失败时回补数据库记录,避免“库里有收藏但搜索不到”。 + /// + public async Task 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(); + 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() + .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; + } + + /// + /// 创建收藏分组:创建成功返回 true,同一用户同名分组已存在返回 false。 + /// + public async Task 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; + } + + /// + /// 查询用户的收藏分组及分组下收藏数量,默认分组(分组ID=0)存在收藏时一并返回。 + /// + public async Task> 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(); + + 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; + } + + /// + /// 按 GoodsIds 顺序返回每个商品是否已被该用户收藏(商品编号无效的返回 false)。 + /// 以数据库为准,不依赖索引,索引重建期间结果依然准确。 + /// + public async Task> 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(); + if (goodsIds.Count == 0) + { + return new List(); + } + + var validIds = goodsIds.Where(x => x > 0).Distinct().ToList(); + var favoritedIds = new HashSet(); + + 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(); + } + + /// + /// 从收藏索引检索当前用户的收藏商品。 + /// + public async Task<(List 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); + } + + /// + /// 移除所有用户收藏索引并重新创建,然后从 user_goods_favorites 关联 goods 全量回填。 + /// + public async Task 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(); + 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(); + + 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(); + 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; + } + + /// + /// 收藏落库成功但索引写入失败时的补偿:撤销本次新增的收藏记录、还原被修改的收藏分组。 + /// + private async Task CompensateWriteFailureAsync(List insertedIds, List<(long Id, int PreviousGroupId)> changedGroupIds) + { + try + { + if (insertedIds.Count > 0) + { + await _favoriteRepository.Orm.Delete() + .Where(x => insertedIds.Contains(x.Id)) + .ExecuteAffrowsAsync(); + } + + foreach (var (id, previousGroupId) in changedGroupIds) + { + await _favoriteRepository.Orm.Update() + .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); + } + } + + /// + /// 取分组名:分组ID <= 0 返回默认分组名,分组不存在返回空串。 + /// + private async Task 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; + } + + /// + /// 组装收藏索引文档:收藏记录 + 商品快照 + 分组名。 + /// + 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, + }; + } + + /// + /// 解析排序方式,非法值一律按“收藏时间倒序”处理。 + /// + private static UserFavoritesSortBy ParseSortBy(string? sortBy) + { + if (string.IsNullOrWhiteSpace(sortBy)) + { + return UserFavoritesSortBy.CreatedTimeDesc; + } + + return Enum.TryParse(sortBy, ignoreCase: true, out var value) + ? value + : UserFavoritesSortBy.CreatedTimeDesc; + } + } +} \ No newline at end of file diff --git a/Modules/CloudProduct/appsettings.json b/Modules/CloudProduct/appsettings.json index 06b88fb..6d24df0 100644 --- a/Modules/CloudProduct/appsettings.json +++ b/Modules/CloudProduct/appsettings.json @@ -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" diff --git a/Store.API/Nlog.config b/Store.API/Nlog.config new file mode 100644 index 0000000..33635c7 --- /dev/null +++ b/Store.API/Nlog.config @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Store.API/Program.cs b/Store.API/Program.cs index 0304618..ee73e59 100644 --- a/Store.API/Program.cs +++ b/Store.API/Program.cs @@ -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); diff --git a/Store.API/Properties/launchSettings.json b/Store.API/Properties/launchSettings.json index 078e74e..7927d11 100644 --- a/Store.API/Properties/launchSettings.json +++ b/Store.API/Properties/launchSettings.json @@ -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" } diff --git a/Store.API/Store.Api.csproj b/Store.API/Store.Api.csproj index bf9f376..ca96a0d 100644 --- a/Store.API/Store.Api.csproj +++ b/Store.API/Store.Api.csproj @@ -3,6 +3,8 @@ enable enable + zh-Hans;zh-Hant;zh;en + diff --git a/Store.API/appsettings.json b/Store.API/appsettings.json index 82d3a32..9c0aeb9 100644 --- a/Store.API/appsettings.json +++ b/Store.API/appsettings.json @@ -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;", diff --git a/Store.API/dotnet-tools.json b/Store.API/dotnet-tools.json new file mode 100644 index 0000000..39007e0 --- /dev/null +++ b/Store.API/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.13", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file