Files
StoreWeb/Plugins/CloudProduct/Services/UserFavoritesService.cs
T
2026-11-06 19:25:36 +08:00

643 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MAX.Core.DependencyInjection;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using MAX.Framework.Validation;
using Microsoft.Extensions.Logging;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Enums;
using Store.Modules.CloudProduct.Repositorys;
using Store.Users.Contracts;
using System.Globalization;
namespace Store.Modules.CloudProduct.Services
{
/// <summary>
/// 用户收藏商品业务:收藏/取消收藏(数据库 + 收藏索引同步写入)、收藏分组维护、收藏状态查询、收藏检索与索引重建。
/// </summary>
public interface IUserFavoritesService : IScopedDependency
{
/// <summary>批量收藏商品,返回本次成功收藏的商品数量(已收藏的商品视为修改收藏分组)</summary>
Task<int> AddFavoritesAsync(AddUserFavoriteRequest request);
/// <summary>按用户和商品编号更新已存在的收藏记录。</summary>
Task SaveProductSafetyAsync(SaveProductSafetyRequest 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 IUserQueryService _queryService;
private readonly GoodsIndexLock _indexLock;
private readonly IUserFavoritesIndexService _indexService;
private readonly ILogger<UserFavoritesService> _logger;
public UserFavoritesService(
UserGoodsFavoriteRepository favoriteRepository,
UserGoodsFavoriteGroupRepository groupRepository,
GoodsRepository goodsRepository,
IUserQueryService queryService,
IUserFavoritesIndexService indexService,
ILogger<UserFavoritesService> logger, GoodsIndexLock indexLock)
{
_favoriteRepository = favoriteRepository;
_groupRepository = groupRepository;
_goodsRepository = goodsRepository;
_queryService = queryService;
_indexService = indexService;
_indexLock = indexLock;
_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 mainUser = await _queryService.GetMainUser(userName);
userName = mainUser.UserName;
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);
}
await using var lease = await _indexLock.AcquireFavoritesAsync();
// 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 SaveProductSafetyAsync(SaveProductSafetyRequest request)
{
RequestValidator.Validate(request);
if (!long.TryParse(request.ProductId, NumberStyles.None, CultureInfo.InvariantCulture, out var goodsId) || goodsId <= 0)
throw new AppException("商品编号必须是正整数", ResultCode.ValidationError);
var userName = request.UserName.Trim();
await using var lease = await _indexLock.AcquireFavoritesAsync();
using var work = _favoriteRepository.Orm.CreateUnitOfWork();
work.GetOrBeginTransaction();
var favorite = await work.Orm.Select<UserGoodsFavoriteEntity>()
.Where(x => x.UserName == userName && x.GoodsId == goodsId).FirstAsync()
?? throw new AppException("该用户尚未收藏此商品", ResultCode.NotFound);
await work.Orm.Update<UserGoodsFavoriteEntity>().Set(x => x.IsSafe, request.IsSafe)
.Where(x => x.Id == favorite.Id).ExecuteAffrowsAsync();
// 重复上报也同步索引,索引明确失败时由工作单元回滚数据库。
await _indexService.UpdateSafetyAsync(UserFavoritesSearchDocument.BuildId(favorite.UserName, goodsId), request.IsSafe!.Value);
work.Commit();
}
/// <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);
}
await using var lease = await _indexLock.AcquireFavoritesAsync();
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);
}
var mainUser = await _queryService.GetMainUser(userName);
request.UserName = mainUser.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()
{
await using var lease = await _indexLock.AcquireFavoritesAsync();
// 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 &lt;= 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,
IsSafe = favorite.IsSafe,
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,
IsSafe = document.IsSafe,
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;
}
}
}