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