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; } } }