把一些项目移动到插件中

This commit is contained in:
chfei committed 2026-10-22 15:12:56 +08:00
1 parent d0f9f00b76
commit 5ff52dea92
60 files changed
+410 -27

No files matched your search

-118
View File
@@ -1,118 +0,0 @@
using Hangfire;
using Hangfire.MySql;
using HangfireBasicAuthenticationFilter;
using MAX.Core.Interfaces;
using MAX.Framework.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MySql.Data.MySqlClient;
using Store.Modules.CloudProduct.Services;
namespace Store.Modules.CloudProduct
{
public class CloudProductModule : IModule
{
/// <summary>
/// 本模块自己的配置文件
/// </summary>
public const string ConfigurationFileName = "appsettings.CloudProduct.json";
public int Priority => IntPriority.Normal;
/// <summary>
/// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
/// </summary>
public void AddModule(IHostApplicationBuilder host)
{
IServiceCollection services = host.Services;
IConfigurationManager configuration = host.Configuration;
if (!string.IsNullOrWhiteSpace(ConfigurationFileName))
{
string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(AppContext.BaseDirectory, ConfigurationFileName);
configuration.AddJsonFile(path, optional: false, reloadOnChange: true);
}
//// 本模块的配置节(Search / RawData / Indexing / Delete)都在模块自己的配置文件里,
//// 关键项缺失直接报错,避免 OpenSearch 地址为空、原始数据写到意外目录之类的隐性故障
RequireConfiguration(configuration, "Search:Url");
RequireConfiguration(configuration, "Search:IndexName");
RequireConfiguration(configuration, "Search:FavoritesIndexName");
RequireConfiguration(configuration, "RawData:Path");
// ===== Hangfire 后台定时任务(商品分词同步) =====
var hangfireConnection = configuration.GetValue<string>("CloudProductConnection")
?? throw new InvalidOperationException("缺少配置 ConnectionStrings:DefaultConnection,CloudProduct 模块的 Hangfire 无法启动。");
// AllowUserVariables=true:Hangfire.MySqlStorage 的建表/查询语句依赖用户变量
var connectionStringBuilder = new MySqlConnectionStringBuilder(hangfireConnection)
{
AllowUserVariables = true
};
services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseStorage(new MySqlStorage(connectionStringBuilder.ConnectionString, new MySqlStorageOptions() { TablesPrefix = "Hangfire_" })));
services.AddHangfireServer();
services.AddTransient<GoodsIndexJob>();
}
/// <summary>
/// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务
/// </summary>
public void UseModule(IApplicationBuilder app)
{
try
{
var Configuration = app.ApplicationServices.GetService<IConfiguration>()!;
// Hangfire 后台任务看板(默认地址 /hangfire)
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[]
{
new HangfireCustomBasicAuthenticationFilter(){
User = Configuration.GetValue<string>("Hangfire:Dashboard:User") ?? "admin",
Pass = Configuration.GetValue<string>("Hangfire:Dashboard:Pass") ?? "1234560"
}
}
});
// 注册定时任务:把未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引
var cron = Configuration.GetValue<string>("Indexing:Cron") ?? "*/5 * * * *";
// 用容器里的 IRecurringJobManager,而不是静态 RecurringJob:
// AddHangfire 只把 JobStorage 注册进容器(没有设置 JobStorage.Current),静态 API 在 Build() 之后调用会直接抛异常。
app.ApplicationServices.GetRequiredService<IRecurringJobManager>().AddOrUpdate<GoodsIndexJob>(
"goods-index-pending",
job => job.ProcessPendingAsync(),
cron);
}
catch (Exception ex)
{
// 数据库不可用/表未初始化时避免阻断应用启动(看板与定时任务都会连库),恢复后重启即可重新装配
Console.WriteLine("[Hangfire] 看板/定时任务装配失败: " + ex.Message);
}
}
/// <summary>
/// 校验模块配置项存在(值来自模块自己的 <see cref="ConfigurationFileName"/>)
/// </summary>
private static void RequireConfiguration(IConfiguration configuration, string key)
{
if (string.IsNullOrWhiteSpace(configuration.GetValue<string>(key)))
{
throw new InvalidOperationException($"缺少配置 {key},请检查模块配置文件 {ConfigurationFileName}。");
}
}
}
}
@@ -1,404 +0,0 @@
using Asp.Versioning;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Store.Framework.Base;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.ImportConverters;
using Store.Modules.CloudProduct.Services;
using System.Text.Json.Nodes;
namespace Store.Modules.CloudProduct.Controllers
{
[ApiVersionNeutral]
[Route("api/CloudGoods/[action]")]
public class GoodsController : AppControllerBase
{
private readonly IGoodsService _goodsService;
private readonly IOpenSearchService _searchService;
private readonly IRawDataStorage _rawDataStorage;
private readonly GoodsIndexJob _goodsIndexJob;
public GoodsController(IGoodsService goodsService, IOpenSearchService searchService, IRawDataStorage rawDataStorage, GoodsIndexJob goodsIndexJob)
{
_goodsService = goodsService;
_searchService = searchService;
_rawDataStorage = rawDataStorage;
_goodsIndexJob = goodsIndexJob;
}
[HttpGet]
public async Task<string> Time()
{
return DateTime.Now.ToLongDateString();
}
[HttpGet]
public async Task Job()
{
await _goodsIndexJob.ProcessPendingAsync();
}
/// <summary>
/// 搜索
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
[HttpPost]
public async Task<PageData<GoodsEntity>> SearchGoods(GoodsSearchRequest request)
{
// 1. 参数基础校验
if (request == null)
{
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.Platform.HasValue && request.Platform < 0)
{
throw new AppException("平台类型无效", ResultCode.ValidationError);
}
// 10. 至少有一个搜索条件
if (string.IsNullOrWhiteSpace(request.Title) &&
!request.MinPrice.HasValue && !request.MaxPrice.HasValue &&
!request.CatId1.HasValue && !request.CatId2.HasValue &&
!request.CatId3.HasValue && !request.CatId4.HasValue &&
!request.MinSoldQuantity.HasValue && !request.MaxSoldQuantity.HasValue &&
!request.Platform.HasValue)
{
throw new AppException("请至少提供一个搜索条件", ResultCode.ValidationError);
}
var result = await _goodsService.SearchGoodsAsync(request);
return new PageData<GoodsEntity>
{
List = result.List,
Total = result.Total,
PageSize = request.PageSize,
PageIndex = request.PageIndex,
};
}
/// <summary>
/// 分词搜索,直接返回索引中的商品摘要(路由保持 SearchGoodsToIds 不变)
/// </summary>
[HttpGet]
[ActionName("SearchGoodsToIds")]
public async Task<PageData<GoodsSearchDocument>> SearchGoodsSummary(string keyword, int pageindex = 1, int pagesize = 20)
{
if (string.IsNullOrWhiteSpace(keyword))
{
throw new AppException("填写关键字", ResultCode.ValidationError);
}
var result = await _searchService.SearchByTitleAsync(new GoodsSearchRequest
{
Title = keyword,
PageIndex = pageindex,
PageSize = pagesize,
});
return new PageData<GoodsSearchDocument>
{
PageIndex = pageindex,
PageSize = pagesize,
List = result.Documents,
Total = result.Total,
};
}
/// <summary>
/// 导入测试数据:读取 ImportConverters/data.txt 并走完整解析 + 入库 + 分词流程。
/// </summary>
[HttpGet]
public async Task<GoodsImportResult> ImportTestData()
{
var filePath = GetDataFilePath();
if (!System.IO.File.Exists(filePath))
{
throw new AppException($"找不到测试数据文件: {filePath}", ResultCode.NotFound);
}
var content = await System.IO.File.ReadAllTextAsync(filePath);
var goods = GoodsConverterFactory.ConvertContent(content);
if (goods == null)
{
throw new AppException("未找到匹配的转换器,无法解析测试数据");
}
return await _goodsService.ImportGoodsAsync(new[]
{
new GoodsImportItem { Goods = goods, RawData = content }
});
}
/// <summary>
/// 导入接口:接收客户端批量上传的 JSON 文件,解析后入库并写入分词索引。
/// </summary>
[HttpPost]
public async Task<GoodsImportResult> ImportFiles([FromForm] List<IFormFile> files)
{
if (files == null || files.Count == 0)
{
throw new AppException("请选择要导入的 JSON 文件", ResultCode.ValidationError);
}
var items = new List<GoodsImportItem>();
var parseErrors = new List<string>();
foreach (var file in files)
{
if (file == null || file.Length == 0)
{
continue;
}
try
{
using var reader = new StreamReader(file.OpenReadStream());
var content = await reader.ReadToEndAsync();
var goods = GoodsConverterFactory.ConvertContent(content);
if (goods == null)
{
parseErrors.Add($"{file.FileName}: 未找到匹配的转换器");
}
else
{
items.Add(new GoodsImportItem { Goods = goods, RawData = content });
}
}
catch (Exception ex)
{
parseErrors.Add($"{file.FileName}: {ex.Message}");
}
}
var result = await _goodsService.ImportGoodsAsync(items);
result.FailedCount += parseErrors.Count;
result.Errors.InsertRange(0, parseErrors);
return result;
}
/// <summary>
/// 查询 OpenSearch 状态。
/// </summary>
[HttpGet]
public async Task<OpenSearchStatusDto> GetOpenSearchStatus()
{
return await _searchService.GetStatusAsync();
}
/// <summary>
/// 检查搜索索引是否存在。
/// </summary>
[HttpGet]
public async Task<bool> IndexExists()
{
return await _searchService.IndexExistsAsync();
}
/// <summary>
/// 检查索引映射(分析器)是否符合当前服务要求。
/// </summary>
[HttpGet]
public async Task<bool> IndexMappingHealthy()
{
return await _searchService.IsIndexMappingHealthyAsync();
}
/// <summary>
/// 创建搜索索引(含 IK 分词映射,已存在且映射正常时跳过)。
/// </summary>
[HttpPost]
public async Task<bool> CreateIndex()
{
return await _searchService.CreateIndexAsync();
}
/// <summary>
/// 删除搜索索引。
/// </summary>
[HttpPost]
public async Task DeleteIndex()
{
await _searchService.DeleteIndexAsync();
}
/// <summary>
/// 手动刷新搜索索引,使最新写入立即可搜索。
/// </summary>
[HttpPost]
public async Task RefreshIndex()
{
await _searchService.RefreshIndexAsync();
}
/// <summary>
/// 手动新增或覆盖一条搜索文档。
/// </summary>
[HttpPost]
public async Task AddSearchDocument([FromBody] GoodsSearchDocument document)
{
if (document == null || document.Id <= 0)
{
throw new AppException("请提供有效的搜索文档", ResultCode.ValidationError);
}
await _searchService.AddDocumentAsync(document);
}
/// <summary>
/// 读取指定商品详情:解压缩原始数据并返回 JSON。
/// </summary>
[HttpPost]
public async Task<JsonNode> GetGoodsDetail([FromQuery] long goodsId)
{
var raw = await GetRawDataOrThrowAsync(goodsId);
var node = JsonNode.Parse(raw);
return node ?? throw new AppException("商品详情解析失败", ResultCode.BusinessError);
}
/// <summary>
/// 读取指定商品的原始数据(解压缩后原样返回)。
/// </summary>
[HttpPost]
public async Task<string> GetGoodsRawData([FromQuery] long goodsId)
{
return await GetRawDataOrThrowAsync(goodsId);
}
/// <summary>
/// 重新从数据库读取指定商品,覆盖 OpenSearch 已有分词并重新分词。
/// ids 为空时重建全部商品。
/// </summary>
[HttpPost]
public async Task<long> RebuildIndex([FromBody] long[]? ids)
{
return await _goodsService.RebuildSearchIndexAsync(ids);
}
/// <summary>
/// 删除商品(按商品编号)。是否软删除由配置 Delete:SoftDelete 决定,
/// 软删除将 Status 置 1,硬删除物理删除记录;两种方式都会删除 OpenSearch 中对应文档。
/// </summary>
[HttpPost]
public async Task<long> DeleteGoods([FromBody] long[]? ids)
{
if (ids == null || ids.Length == 0)
{
throw new AppException("请提供要删除的商品ID", ResultCode.ValidationError);
}
return await _goodsService.DeleteGoodsAsync(ids);
}
/// <summary>
/// 清空并重建搜索索引(路由保持 ClearSearch 不变)。
/// </summary>
[HttpGet]
[ActionName("ClearSearch")]
public async Task<bool> ClearSearchIndex()
{
return await _searchService.ClearIndexAsync();
}
/// <summary>
/// 统计索引中的文档数(路由保持 GetSearchCount 不变)。
/// </summary>
[HttpGet]
[ActionName("GetSearchCount")]
public async Task<long> GetSearchDocumentCount()
{
return await _searchService.CountDocumentsAsync();
}
private async Task<string> GetRawDataOrThrowAsync(long goodsId)
{
if (goodsId <= 0)
throw new AppException("商品ID无效", ResultCode.ValidationError);
var raw = await _rawDataStorage.ReadAsync(goodsId);
if (raw == null)
throw new AppException($"商品原始数据不存在: {goodsId}", ResultCode.NotFound);
return raw;
}
private static string GetDataFilePath()
{
var outputPath = Path.Combine(AppContext.BaseDirectory, "ImportConverters", "data.txt");
if (System.IO.File.Exists(outputPath))
return outputPath;
var workPath = Path.Combine(Directory.GetCurrentDirectory(), "ImportConverters", "data.txt");
if (System.IO.File.Exists(workPath))
return workPath;
return outputPath;
}
}
}
@@ -1,269 +0,0 @@
using Asp.Versioning;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Mvc;
using Store.Framework.Base;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Services;
namespace Store.Modules.CloudProduct.Controllers
{
/// <summary>
/// 用户收藏商品:收藏/取消收藏(数据库 + 收藏索引同步)、收藏分组、收藏状态查询、收藏检索与索引重建。
/// 请求中的 userName 允许为空,为空时取当前登录用户。
/// </summary>
[ApiVersionNeutral]
[Route("api/CloudGoods/[action]")]
public class UserFavoritesController : AppControllerBase
{
private readonly IUserFavoritesService _favoritesService;
private readonly ICurrentUser _currentUser;
public UserFavoritesController(IUserFavoritesService favoritesService, ICurrentUser currentUser)
{
_favoritesService = favoritesService;
_currentUser = currentUser;
}
/// <summary>
/// 批量收藏商品:写入 user_goods_favorites 并同步写入收藏索引,返回本次成功收藏的商品数量。
/// 商品已收藏时视为修改收藏分组;商品不存在/已删除的编号会被跳过。
/// </summary>
[HttpPost]
public async Task<int> AddFavorite(AddUserFavoriteRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
request.UserName = ResolveUserName(request.UserName);
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
if (request.GoodsIds == null || request.GoodsIds.Count == 0)
{
throw new AppException("请提供要收藏的商品编号", ResultCode.ValidationError);
}
if (request.GroupId < 0)
{
throw new AppException("收藏分组无效", ResultCode.ValidationError);
}
return await _favoritesService.AddFavoritesAsync(request);
}
/// <summary>
/// 批量取消收藏:删除 user_goods_favorites 记录并同步删除收藏索引文档,返回实际取消的条数。
/// </summary>
[HttpPost]
public async Task<int> RemoveFavorites(RemoveUserFavoritesRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
request.UserName = ResolveUserName(request.UserName);
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
if (request.GoodsIds == null || request.GoodsIds.Count == 0)
{
throw new AppException("请提供要取消收藏的商品编号", ResultCode.ValidationError);
}
return await _favoritesService.RemoveFavoritesAsync(request);
}
/// <summary>
/// 创建收藏分组:创建成功返回 true,同名分组已存在返回 false。
/// </summary>
[HttpPost]
public async Task<bool> CreateGroup(CreateUserFavoriteGroupRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
request.UserName = ResolveUserName(request.UserName);
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
if (string.IsNullOrWhiteSpace(request.GroupName))
{
throw new AppException("分组名不能为空", ResultCode.ValidationError);
}
return await _favoritesService.CreateGroupAsync(request);
}
/// <summary>
/// 查询用户的收藏分组(含默认分组)及分组下收藏数量。
/// </summary>
[HttpGet]
public async Task<List<UserFavoriteGroupDto>> GetGroups(string? userName = null)
{
var resolved = ResolveUserName(userName);
if (string.IsNullOrWhiteSpace(resolved))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
return await _favoritesService.GetGroupsAsync(resolved);
}
/// <summary>
/// 按 GoodsIds 顺序返回每个商品是否已被收藏(如 [true,false,...]),前台可按顺序直接渲染。
/// </summary>
[HttpPost]
public async Task<List<bool>> GetFavoriteStatus(UserFavoriteStatusRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
request.UserName = ResolveUserName(request.UserName);
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
return await _favoritesService.GetFavoriteStatusAsync(request);
}
/// <summary>
/// 检索用户收藏的商品(标题、销量、价格、类目、分组)。
/// </summary>
[HttpPost]
public async Task<PageData<UserFavoriteItem>> SearchFavorites(UserFavoritesSearchRequest request)
{
// 1. 参数基础校验
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
request.UserName = ResolveUserName(request.UserName);
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
// 2. 分页参数校验
if (request.PageIndex < 1)
{
throw new AppException("页码必须大于0", ResultCode.ValidationError);
}
if (request.PageSize < 1 || request.PageSize > 2000)
{
throw new AppException("每页数量必须在1-2000之间", ResultCode.ValidationError);
}
// 3. 价格区间校验
if (request.MinPrice.HasValue && request.MinPrice < 0)
{
throw new AppException("最低价格不能小于0", ResultCode.ValidationError);
}
if (request.MaxPrice.HasValue && request.MaxPrice < 0)
{
throw new AppException("最高价格不能小于0", ResultCode.ValidationError);
}
if (request.MinPrice.HasValue && request.MaxPrice.HasValue && request.MinPrice > request.MaxPrice)
{
throw new AppException("最低价格不能大于最高价格", ResultCode.ValidationError);
}
// 4. 销量区间校验
if (request.MinSoldQuantity.HasValue && request.MinSoldQuantity < 0)
{
throw new AppException("最低销量不能小于0", ResultCode.ValidationError);
}
if (request.MaxSoldQuantity.HasValue && request.MaxSoldQuantity < 0)
{
throw new AppException("最高销量不能小于0", ResultCode.ValidationError);
}
if (request.MinSoldQuantity.HasValue && request.MaxSoldQuantity.HasValue &&
request.MinSoldQuantity > request.MaxSoldQuantity)
{
throw new AppException("最低销量不能大于最高销量", ResultCode.ValidationError);
}
// 5. 类目层级校验
if (request.CatId2.HasValue && !request.CatId1.HasValue)
{
throw new AppException("选择二级类目时必须先选择一级类目", ResultCode.ValidationError);
}
if (request.CatId3.HasValue && !request.CatId2.HasValue)
{
throw new AppException("选择三级类目时必须先选择二级类目", ResultCode.ValidationError);
}
if (request.CatId4.HasValue && !request.CatId3.HasValue)
{
throw new AppException("选择四级类目时必须先选择三级类目", ResultCode.ValidationError);
}
// 6. 分组校验
if (request.GroupId.HasValue && request.GroupId < 0)
{
throw new AppException("收藏分组无效", ResultCode.ValidationError);
}
// 7. 平台校验
if (request.Platform.HasValue && request.Platform < 0)
{
throw new AppException("平台类型无效", ResultCode.ValidationError);
}
var result = await _favoritesService.SearchFavoritesAsync(request);
return new PageData<UserFavoriteItem>
{
List = result.List,
Total = result.Total,
PageSize = request.PageSize,
PageIndex = request.PageIndex,
};
}
/// <summary>
/// 移除所有用户收藏索引并重新创建,随后按 user_goods_favorites 关联 goods 全量回填。
/// </summary>
[HttpPost]
public async Task<long> RebuildSearchIndex()
{
return await _favoritesService.RebuildSearchIndexAsync();
}
/// <summary>
/// 取用户名:显式传入优先,否则取当前登录用户。
/// </summary>
private string ResolveUserName(string? userName)
{
return string.IsNullOrWhiteSpace(userName)
? (_currentUser.UserName ?? string.Empty)
: userName.Trim();
}
}
}
@@ -1,23 +0,0 @@
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 收藏商品请求(支持多选批量收藏)
/// </summary>
public class AddUserFavoriteRequest
{
/// <summary>
/// 用户名(为空时取当前登录用户)
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 商品编号集合(可多选)
/// </summary>
public List<long> GoodsIds { get; set; } = new();
/// <summary>
/// 收藏分组ID(默认0,表示默认分组;大于0时必须存在且属于该用户)
/// </summary>
public int GroupId { get; set; }
}
}
@@ -1,18 +0,0 @@
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 创建收藏分组请求
/// </summary>
public class CreateUserFavoriteGroupRequest
{
/// <summary>
/// 用户名(为空时取当前登录用户)
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 分组名
/// </summary>
public string GroupName { get; set; } = string.Empty;
}
}
@@ -1,20 +0,0 @@
using Store.Modules.CloudProduct.Entities;
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 导入项:商品模型 + 原始数据
/// </summary>
public class GoodsImportItem
{
/// <summary>
/// 解析后的商品模型
/// </summary>
public GoodsEntity Goods { get; set; } = null!;
/// <summary>
/// 原始数据(JSON 原文,压缩后落盘)
/// </summary>
public string RawData { get; set; } = string.Empty;
}
}
@@ -1,30 +0,0 @@
using System.Collections.Generic;
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 商品导入结果
/// </summary>
public class GoodsImportResult
{
/// <summary>
/// 成功条数
/// </summary>
public int SuccessCount { get; set; }
/// <summary>
/// 失败条数
/// </summary>
public int FailedCount { get; set; }
/// <summary>
/// 总条数
/// </summary>
public int Total => SuccessCount + FailedCount;
/// <summary>
/// 错误明细
/// </summary>
public List<string> Errors { get; set; } = new();
}
}
@@ -1,48 +0,0 @@
using OpenSearch.Client;
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 商品搜索文档:OpenSearch 索引中的商品模型,Id 为主键,Title 用于分词搜索
/// </summary>
public class GoodsSearchDocument
{
[Number(NumberType.Byte, Name = "platform")]
public byte Platform { get; set; }
[Number(NumberType.Long, Name = "id")]
public long Id { get; set; }
[Text(Name = "title", Analyzer = "ik_max_word", SearchAnalyzer = "ik_smart")]
public string Title { get; set; } = string.Empty;
[Text(Name = "thumbUrl")]
public string? ThumbUrl { get; set; } = string.Empty;
/// <summary>一级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_1")]
public int? CatId1 { get; set; }
/// <summary>二级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_2")]
public int? CatId2 { get; set; }
/// <summary>三级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_3")]
public int? CatId3 { get; set; }
/// <summary>四级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_4")]
public int? CatId4 { get; set; }
/// <summary>最低价快照,用于价格区间过滤和按价格排序</summary>
[Number(NumberType.Long, Name = "min_price")]
public long? MinPrice { get; set; }
/// <summary>销量,用于按销量排序</summary>
[Number(NumberType.Integer, Name = "sold_quantity")]
public int SoldQuantity { get; set; }
public override string ToString() => $"[Id={Id}] Title={Title} Cat=({CatId1},{CatId2},{CatId3},{CatId4}) Price={MinPrice} Sold={SoldQuantity}";
}
}
@@ -1,78 +0,0 @@
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 商品搜索请求
/// </summary>
public class GoodsSearchRequest
{
/// <summary>
/// 标题(模糊搜索)
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 最低价格(分)
/// </summary>
public long? MinPrice { get; set; }
/// <summary>
/// 最高价格(分)
/// </summary>
public long? MaxPrice { get; set; }
/// <summary>
/// 一级类目ID
/// </summary>
public int? CatId1 { get; set; }
/// <summary>
/// 二级类目ID
/// </summary>
public int? CatId2 { get; set; }
/// <summary>
/// 三级类目ID
/// </summary>
public int? CatId3 { get; set; }
/// <summary>
/// 四级类目ID
/// </summary>
public int? CatId4 { get; set; }
/// <summary>
/// 最低销量
/// </summary>
public int? MinSoldQuantity { get; set; }
/// <summary>
/// 最高销量
/// </summary>
public int? MaxSoldQuantity { get; set; }
/// <summary>
/// 平台类型
/// </summary>
public byte? Platform { get; set; }
/// <summary>
/// 页码(默认1)
/// </summary>
public int PageIndex { get; set; } = 1;
/// <summary>
/// 每页数量(默认20)
/// </summary>
public int PageSize { get; set; } = 20;
/// <summary>
/// 排序字段
/// </summary>
public string SortBy { get; set; } = "CreatedTime";
/// <summary>
/// 排序方式:asc/desc(默认desc)
/// </summary>
public string SortOrder { get; set; } = "desc";
}
}
@@ -1,108 +0,0 @@
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// OpenSearch 运行状态
/// </summary>
public class OpenSearchStatusDto
{
/// <summary>
/// 节点地址
/// </summary>
public string NodeUrl { get; set; } = string.Empty;
/// <summary>
/// 索引名称
/// </summary>
public string IndexName { get; set; } = string.Empty;
/// <summary>
/// 是否可连接
/// </summary>
public bool Connected { get; set; }
/// <summary>
/// OpenSearch 服务及目标索引是否健康
/// </summary>
public bool Healthy { get; set; }
/// <summary>
/// 集群名称
/// </summary>
public string? ClusterName { get; set; }
/// <summary>
/// 集群状态(green、yellow 或 red)
/// </summary>
public string? ClusterStatus { get; set; }
/// <summary>
/// 集群节点总数
/// </summary>
public int NumberOfNodes { get; set; }
/// <summary>
/// 数据节点数量
/// </summary>
public int NumberOfDataNodes { get; set; }
/// <summary>
/// 活跃主分片数量
/// </summary>
public int ActivePrimaryShards { get; set; }
/// <summary>
/// 活跃分片总数
/// </summary>
public int ActiveShards { get; set; }
/// <summary>
/// 正在迁移的分片数量
/// </summary>
public int RelocatingShards { get; set; }
/// <summary>
/// 正在初始化的分片数量
/// </summary>
public int InitializingShards { get; set; }
/// <summary>
/// 未分配的分片数量
/// </summary>
public int UnassignedShards { get; set; }
/// <summary>
/// 索引是否存在
/// </summary>
public bool IndexExists { get; set; }
/// <summary>
/// 索引映射是否符合当前服务要求
/// </summary>
public bool MappingHealthy { get; set; }
/// <summary>
/// 标题字段的索引分析器
/// </summary>
public string? IndexAnalyzer { get; set; }
/// <summary>
/// 标题字段的搜索分析器
/// </summary>
public string? SearchAnalyzer { get; set; }
/// <summary>
/// 文档总数(索引不存在或读取失败时为 -1)
/// </summary>
public long DocumentCount { get; set; } = -1;
/// <summary>
/// 状态检测耗时(毫秒)
/// </summary>
public long ElapsedMilliseconds { get; set; }
/// <summary>
/// 状态说明或错误信息
/// </summary>
public string? Message { get; set; }
}
}
@@ -1,18 +0,0 @@
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 取消收藏(批量)请求
/// </summary>
public class RemoveUserFavoritesRequest
{
/// <summary>
/// 用户名(为空时取当前登录用户)
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 要取消收藏的商品编号集合
/// </summary>
public List<long> GoodsIds { get; set; } = new();
}
}
@@ -1,20 +0,0 @@
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 用户收藏分组
/// </summary>
public class UserFavoriteGroupDto
{
/// <summary>分组ID</summary>
public long Id { get; set; }
/// <summary>用户名</summary>
public string UserName { get; set; } = string.Empty;
/// <summary>分组名</summary>
public string GroupName { get; set; } = string.Empty;
/// <summary>该分组下的收藏数量</summary>
public long FavoriteCount { get; set; }
}
}
@@ -1,53 +0,0 @@
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 用户收藏商品(索引文档回显模型)
/// </summary>
public class UserFavoriteItem
{
/// <summary>收藏记录自增主键</summary>
public long Id { get; set; }
/// <summary>用户名</summary>
public string UserName { get; set; } = string.Empty;
/// <summary>收藏分组ID(0 表示默认分组)</summary>
public int GroupId { get; set; }
/// <summary>收藏分组名</summary>
public string GroupName { get; set; } = string.Empty;
/// <summary>商品编号</summary>
public long GoodsId { get; set; }
/// <summary>平台类型</summary>
public byte Platform { get; set; }
/// <summary>商品标题</summary>
public string Title { get; set; } = string.Empty;
/// <summary>缩略图</summary>
public string? ThumbUrl { get; set; }
/// <summary>一级类目ID</summary>
public int? CatId1 { get; set; }
/// <summary>二级类目ID</summary>
public int? CatId2 { get; set; }
/// <summary>三级类目ID</summary>
public int? CatId3 { get; set; }
/// <summary>四级类目ID</summary>
public int? CatId4 { get; set; }
/// <summary>最低价快照(分)</summary>
public long? MinPrice { get; set; }
/// <summary>销量</summary>
public int SoldQuantity { get; set; }
/// <summary>收藏时间</summary>
public DateTime CreatedAt { get; set; }
}
}
@@ -1,18 +0,0 @@
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 查询商品是否已被收藏请求:按 GoodsIds 顺序返回是否已收藏
/// </summary>
public class UserFavoriteStatusRequest
{
/// <summary>
/// 用户名(为空时取当前登录用户)
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 商品编号集合
/// </summary>
public List<long> GoodsIds { get; set; } = new();
}
}
@@ -1,87 +0,0 @@
using OpenSearch.Client;
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 用户收藏商品搜索文档:OpenSearch 索引中的收藏模型。
/// 一条收藏记录 = 用户 + 商品快照(标题/价格/销量/类目)+ 收藏分组,
/// 文档 Id 固定为 "{user_name}:{goods_id}"(表 uk_user_goods 保证唯一),便于收藏/取消收藏时幂等覆盖与删除。
/// </summary>
public class UserFavoritesSearchDocument
{
/// <summary>
/// 文档主键(同时作为 OpenSearch 的 _id):{user_name}:{goods_id}
/// </summary>
[Keyword(Name = "id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// 收藏记录自增主键(user_goods_favorites.id)
/// </summary>
[Number(NumberType.Long, Name = "fav_id")]
public long FavId { get; set; }
/// <summary>用户名(收藏归属人,检索时强制过滤)</summary>
[Keyword(Name = "user_name")]
public string UserName { get; set; } = string.Empty;
/// <summary>收藏分组ID(0 表示默认分组)</summary>
[Number(NumberType.Integer, Name = "group_id")]
public int GroupId { get; set; }
/// <summary>收藏分组名(分组ID为0时为默认分组名)</summary>
[Keyword(Name = "group_name")]
public string GroupName { get; set; } = string.Empty;
/// <summary>商品编号</summary>
[Number(NumberType.Long, Name = "goods_id")]
public long GoodsId { get; set; }
/// <summary>平台类型(1 PDD、2 WX)</summary>
[Number(NumberType.Byte, Name = "platform")]
public byte Platform { get; set; }
/// <summary>商品标题,用于分词搜索</summary>
[Text(Name = "title", Analyzer = "ik_max_word", SearchAnalyzer = "ik_smart")]
public string Title { get; set; } = string.Empty;
/// <summary>缩略图</summary>
[Text(Name = "thumb_url")]
public string? ThumbUrl { get; set; }
/// <summary>一级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_1")]
public int? CatId1 { get; set; }
/// <summary>二级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_2")]
public int? CatId2 { get; set; }
/// <summary>三级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_3")]
public int? CatId3 { get; set; }
/// <summary>四级类目ID</summary>
[Number(NumberType.Integer, Name = "cat_id_4")]
public int? CatId4 { get; set; }
/// <summary>最低价快照(分),用于价格区间过滤和按价格排序</summary>
[Number(NumberType.Long, Name = "min_price")]
public long? MinPrice { get; set; }
/// <summary>销量,用于按销量排序</summary>
[Number(NumberType.Integer, Name = "sold_quantity")]
public int SoldQuantity { get; set; }
/// <summary>收藏时间,默认按此字段倒序</summary>
[Date(Name = "created_at")]
public DateTime CreatedAt { get; set; }
/// <summary>
/// 生成文档主键:{user_name}:{goods_id}
/// </summary>
public static string BuildId(string userName, long goodsId) => $"{userName}:{goodsId}";
public override string ToString() => $"[Id={Id}] User={UserName} Goods={GoodsId} Group={GroupId}({GroupName}) Title={Title} Price={MinPrice} Sold={SoldQuantity}";
}
}
@@ -1,88 +0,0 @@
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// 用户收藏商品搜索请求(仅检索当前用户自己的收藏)
/// </summary>
public class UserFavoritesSearchRequest
{
/// <summary>
/// 用户名(为空时取当前登录用户)
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 标题关键字(分词模糊搜索)
/// </summary>
public string? Title { get; set; }
/// <summary>
/// 收藏分组ID(0 表示默认分组)
/// </summary>
public int? GroupId { get; set; }
/// <summary>
/// 收藏分组名(与分组ID二选一,同时传入时以分组ID为准)
/// </summary>
public string? GroupName { get; set; }
/// <summary>
/// 最低价格(分)
/// </summary>
public long? MinPrice { get; set; }
/// <summary>
/// 最高价格(分)
/// </summary>
public long? MaxPrice { get; set; }
/// <summary>
/// 一级类目ID
/// </summary>
public int? CatId1 { get; set; }
/// <summary>
/// 二级类目ID
/// </summary>
public int? CatId2 { get; set; }
/// <summary>
/// 三级类目ID
/// </summary>
public int? CatId3 { get; set; }
/// <summary>
/// 四级类目ID
/// </summary>
public int? CatId4 { get; set; }
/// <summary>
/// 最低销量
/// </summary>
public int? MinSoldQuantity { get; set; }
/// <summary>
/// 最高销量
/// </summary>
public int? MaxSoldQuantity { get; set; }
/// <summary>
/// 平台类型
/// </summary>
public byte? Platform { get; set; }
/// <summary>
/// 页码(默认1)
/// </summary>
public int PageIndex { get; set; } = 1;
/// <summary>
/// 每页数量(默认20)
/// </summary>
public int PageSize { get; set; } = 20;
/// <summary>
/// 排序方式:CreatedTimeDesc(默认,收藏时间倒序)/ CreatedTimeAsc / PriceAsc / PriceDesc / SoldDesc
/// </summary>
public string SortBy { get; set; } = "CreatedTimeDesc";
}
}
@@ -1,120 +0,0 @@
using FreeSql.DataAnnotations;
using System.Text.Json.Serialization;
namespace Store.Modules.CloudProduct.Entities
{
/// <summary>
/// 商品表实体(对应数据库表 goods)
/// </summary>
[Table(Name = "goods")]
public class GoodsEntity
{
/// <summary>
/// 自增ID
/// </summary>
[Column(IsPrimary = true, IsIdentity = true)]
[JsonIgnore]
public long Id { get; set; }
/// <summary>
/// 平台类型
/// 1 PDD
/// </summary>
[Column(IsNullable = false)]
public byte Platform { get; set; }
/// <summary>
/// 商品编号
/// </summary>
[Column(Name = "goods_id", IsNullable = false)]
public long GoodsId { get; set; }
/// <summary>
/// 标题
/// </summary>
[Column(Name = "title", IsNullable = false, StringLength = 500)]
public string Title { get; set; } = string.Empty;
/// <summary>
/// 描述
/// </summary>
[Column(Name = "description", IsNullable = true)]
public string? Description { get; set; }
/// <summary>
/// 缩略图
/// </summary>
[Column(Name = "thumb_url", IsNullable = true, StringLength = 512)]
public string? ThumbUrl { get; set; }
/// <summary>
/// 一级类目ID
/// </summary>
[Column(Name = "cat_id_1", IsNullable = true)]
public int? CatId1 { get; set; }
/// <summary>
/// 二级类目ID
/// </summary>
[Column(Name = "cat_id_2", IsNullable = true)]
public int? CatId2 { get; set; }
/// <summary>
/// 三级类目ID
/// </summary>
[Column(Name = "cat_id_3", IsNullable = true)]
public int? CatId3 { get; set; }
/// <summary>
/// 四级类目ID
/// </summary>
[Column(Name = "cat_id_4", IsNullable = true)]
public int? CatId4 { get; set; }
/// <summary>
/// 最低价快照(元),导入时计算
/// </summary>
[Column(Name = "min_price")]
public long? MinPrice { get; set; }
/// <summary>
/// 最高价快照(元),导入时计算
/// </summary>
[Column(Name = "max_price")]
public long? MaxPrice { get; set; }
/// <summary>
/// 销量
/// </summary>
[Column(Name = "sold_quantity", IsNullable = false)]
public int SoldQuantity { get; set; }
/// <summary>
/// 状态:0正常 1已删除
/// </summary>
[Column(IsNullable = false)]
[JsonIgnore]
public byte Status { get; set; }
/// <summary>
/// 创建时间
/// </summary>
[Column(Name = "createdTime", IsNullable = false, ServerTime = DateTimeKind.Utc)]
[JsonIgnore]
public DateTime CreatedTime { get; set; }
/// <summary>
/// 更新时间(自动更新)
/// </summary>
[Column(Name = "updatedTime", IsNullable = false, ServerTime = DateTimeKind.Utc)]
[JsonIgnore]
public DateTime UpdatedTime { get; set; }
/// <summary>
/// 是否已分词:0未 1已
/// </summary>
[Column(Name = "is_segmented", IsNullable = false)]
[JsonIgnore]
public byte IsSegmented { get; set; }
}
}
@@ -1,43 +0,0 @@
using FreeSql.DataAnnotations;
using System.Text.Json.Serialization;
namespace Store.Modules.CloudProduct.Entities
{
/// <summary>
/// 用户收藏商品表实体(对应数据库表 user_goods_favorites)
/// </summary>
[Table(Name = "user_goods_favorites")]
public class UserGoodsFavoriteEntity
{
/// <summary>
/// 自增主键
/// </summary>
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
[JsonIgnore]
public long Id { get; set; }
/// <summary>
/// 用户名(同一用户不能重复收藏同一商品)
/// </summary>
[Column(Name = "user_name", IsNullable = false, StringLength = 50)]
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 收藏分组ID(0 表示默认分组),对应 user_goods_favorites_group.id
/// </summary>
[Column(Name = "group_id", IsNullable = false)]
public int GroupId { get; set; }
/// <summary>
/// 商品编号,对应 goods.goods_id
/// </summary>
[Column(Name = "goods_id", IsNullable = false)]
public long GoodsId { get; set; }
/// <summary>
/// 收藏时间
/// </summary>
[Column(Name = "created_at", IsNullable = false)]
public DateTime CreatedAt { get; set; }
}
}
@@ -1,31 +0,0 @@
using FreeSql.DataAnnotations;
using System.Text.Json.Serialization;
namespace Store.Modules.CloudProduct.Entities
{
/// <summary>
/// 用户收藏分组表实体(对应数据库表 user_goods_favorites_group)
/// </summary>
[Table(Name = "user_goods_favorites_group")]
public class UserGoodsFavoriteGroupEntity
{
/// <summary>
/// 自增主键,即收藏表 user_goods_favorites.group_id 的取值
/// </summary>
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
[JsonIgnore]
public long Id { get; set; }
/// <summary>
/// 用户名
/// </summary>
[Column(Name = "user_name", IsNullable = false, StringLength = 50)]
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 分组名
/// </summary>
[Column(Name = "group_name", IsNullable = false, StringLength = 255)]
public string GroupName { get; set; } = string.Empty;
}
}
@@ -1,20 +0,0 @@
namespace Store.Modules.CloudProduct.Enums
{
/// <summary>
/// 搜索排序方式
/// </summary>
public enum GoodsSearchSortBy
{
/// <summary>相关性(默认)</summary>
Relevance = 0,
/// <summary>价格从低到高</summary>
PriceAsc = 1,
/// <summary>价格从高到低</summary>
PriceDesc = 2,
/// <summary>销量从高到低</summary>
SoldDesc = 3,
}
}
@@ -1,9 +0,0 @@
namespace Store.Modules.CloudProduct.Enums
{
public enum PlatformType
{
None = 0,
PDD = 1,
WX = 2,
}
}
@@ -1,23 +0,0 @@
namespace Store.Modules.CloudProduct.Enums
{
/// <summary>
/// 用户收藏商品搜索排序方式
/// </summary>
public enum UserFavoritesSortBy
{
/// <summary>按收藏时间倒序(默认)</summary>
CreatedTimeDesc = 0,
/// <summary>按收藏时间正序</summary>
CreatedTimeAsc = 1,
/// <summary>价格从低到高</summary>
PriceAsc = 2,
/// <summary>价格从高到低</summary>
PriceDesc = 3,
/// <summary>销量从高到低</summary>
SoldDesc = 4,
}
}
@@ -1,107 +0,0 @@
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Enums;
using System.Text.Json.Nodes;
namespace Store.Modules.CloudProduct.ImportConverters
{
/// <summary>
/// 导入商品数据转换器
/// </summary>
public interface IGoodsConverter
{
/// <summary>
/// 原始数据(JSON 原文)
/// </summary>
string RawData { get; }
/// <summary>
/// 获取转换器名称
/// </summary>
string ConverterName { get; }
/// <summary>
/// 支持的平台类型
/// </summary>
PlatformType Platform { get; }
/// <summary>
/// 读取内容
/// </summary>
/// <param name="content">JSON 原文内容</param>
void ReadContent(string content);
/// <summary>
/// 判断是否匹配当前转换器
/// </summary>
/// <returns>是否匹配</returns>
bool IsMatch();
/// <summary>
/// 转换为标准商品模型
/// </summary>
GoodsEntity Convert();
}
/// <summary>
/// 转换器基类:统一实现读取、校验与转换骨架,子类只需实现匹配判定与字段映射。
/// </summary>
public abstract class GoodsConverter : IGoodsConverter
{
private bool _isMatch = false;
public string RawData { get; private set; } = null!;
public abstract string ConverterName { get; }
public abstract PlatformType Platform { get; }
void IGoodsConverter.ReadContent(string content)
{
ReadContent(content);
}
protected virtual void ReadContent(string content)
{
RawData = content;
}
public virtual bool IsMatch()
{
_isMatch = true;
return IsMatchCore();
}
/// <summary>
/// 由子类实现的具体匹配判定
/// </summary>
protected abstract bool IsMatchCore();
/// <summary>
/// 带校验的转换(基类统一实现)
/// </summary>
public virtual GoodsEntity Convert()
{
if (_isMatch == false) throw new Exception("未校验数据");
try
{
JsonNode.Parse(RawData);
}
catch { throw new Exception("数据解析失败,不是有效的JSON格式"); }
var goods = ConvertCore();
if (goods == null)
throw new Exception("转换失败");
// 基础校验
if (goods.GoodsId == 0)
throw new Exception("商品ID不能为空");
return goods;
}
/// <summary>
/// 由子类实现的具体字段映射
/// </summary>
protected abstract GoodsEntity ConvertCore();
}
}
@@ -1,54 +0,0 @@
using Store.Modules.CloudProduct.Entities;
using System.Collections.Generic;
using System.IO;
namespace Store.Modules.CloudProduct.ImportConverters
{
/// <summary>
/// 导入商品转换工厂:按顺序尝试各转换器,返回标准商品模型。
/// </summary>
public class GoodsConverterFactory
{
private static readonly List<IGoodsConverter> _converters = new List<IGoodsConverter>
{
new PddGoodsConverter(),
};
/// <summary>
/// 根据本地 JSON 文件路径转换。
/// </summary>
public static GoodsEntity? Convert(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
throw new ArgumentNullException(nameof(filePath));
if (!File.Exists(filePath))
throw new FileNotFoundException("找不到导入文件", filePath);
var content = File.ReadAllText(filePath);
return ConvertContent(content);
}
/// <summary>
/// 根据 JSON 内容字符串转换。
/// </summary>
public static GoodsEntity? ConvertContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
throw new ArgumentNullException(nameof(content));
foreach (var converter in _converters)
{
converter.ReadContent(content);
if (converter.IsMatch())
{
var goods = converter.Convert();
if (goods != null)
return goods;
}
}
return null;
}
}
}
@@ -1,176 +0,0 @@
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Enums;
using System.Text.Json.Nodes;
namespace Store.Modules.CloudProduct.ImportConverters
{
/// <summary>
/// PDD 前端商品详情数据转换器(数据源格式见 data.txt)
/// </summary>
internal class PddGoodsConverter : GoodsConverter
{
public override string ConverterName => nameof(PddGoodsConverter);
public override PlatformType Platform => PlatformType.PDD;
protected override bool IsMatchCore()
{
try
{
if (JsonNode.Parse(RawData) is not JsonObject root)
return false;
// data.txt:顶层包含 goods.goods_id
if (root["goods"] is JsonObject goodsObj && goodsObj["goods_id"] != null)
return true;
// 也兼容顶层直接是商品对象的情况
if (root["goods_id"] != null && (root["goods_name"] != null || root["title"] != null))
return true;
return false;
}
catch
{
return false;
}
}
protected override GoodsEntity ConvertCore()
{
if (JsonNode.Parse(RawData) is not JsonObject root)
throw new Exception("无法解析 PDD 商品数据");
var goodsNode = root["goods"] as JsonObject ?? root;
var priceNode = root["price"] as JsonObject;
var goods = new GoodsEntity
{
Platform = (byte)PlatformType.PDD,
GoodsId = GetLong(goodsNode, "goods_id"),
Title = GetString(goodsNode, "goods_name")
?? GetString(goodsNode, "short_name")
?? string.Empty,
Description = GetString(goodsNode, "share_desc") ?? GetString(goodsNode, "short_name"),
ThumbUrl = GetString(goodsNode, "hd_url") ?? GetString(goodsNode, "thumb_url") ?? GetString(goodsNode, "hd_thumb_url") ?? GetString(goodsNode, "image_url"),
CatId1 = GetIntOrNull(goodsNode, "cat_id_1"),
CatId2 = GetIntOrNull(goodsNode, "cat_id_2"),
CatId3 = GetIntOrNull(goodsNode, "cat_id_3"),
CatId4 = GetIntOrNull(goodsNode, "cat_id_4"),
SoldQuantity = GetInt(goodsNode, "sold_quantity"),
Status = 0,
};
var price = GetPriceRange(priceNode, root);
goods.MinPrice = price.Min;
goods.MaxPrice = price.Max;
return goods;
}
private static string? GetString(JsonObject? obj, string key)
{
if (obj == null || !obj.TryGetPropertyValue(key, out var node) || node == null)
return null;
if (node is JsonValue value && value.TryGetValue<string>(out var str))
return str;
return node.ToJsonString();
}
private static long GetLong(JsonObject? obj, string key)
{
if (obj == null || !obj.TryGetPropertyValue(key, out var node) || node == null)
return 0;
if (node is JsonValue value)
{
if (value.TryGetValue<long>(out var l)) return l;
if (value.TryGetValue<decimal>(out var d)) return (long)d;
if (long.TryParse(value.ToString(), out var l2)) return l2;
}
return 0;
}
private static int GetInt(JsonObject? obj, string key)
{
if (obj == null || !obj.TryGetPropertyValue(key, out var node) || node == null)
return 0;
if (node is JsonValue value)
{
if (value.TryGetValue<int>(out var i)) return i;
if (value.TryGetValue<long>(out var l)) return (int)l;
if (int.TryParse(value.ToString(), out var i2)) return i2;
}
return 0;
}
private static int? GetIntOrNull(JsonObject? obj, string key)
{
if (obj == null || !obj.TryGetPropertyValue(key, out var node) || node == null)
return null;
if (node is JsonValue value)
{
if (value.TryGetValue<int>(out var i) && i > 0) return i;
if (value.TryGetValue<long>(out var l) && l > 0) return (int)l;
if (int.TryParse(value.ToString(), out var i2) && i2 > 0) return i2;
}
return null;
}
private static long? GetLongOrNull(JsonObject? obj, string key)
{
if (obj == null || !obj.TryGetPropertyValue(key, out var node) || node == null)
return null;
if (node is JsonValue value)
{
if (value.TryGetValue<long>(out var d)) return d;
if (value.TryGetValue<long>(out var l)) return l;
if (long.TryParse(value.ToString(), out var d2)) return d2;
}
return null;
}
/// <summary>
/// 计算价格区间。优先使用顶层 price 节点,其次遍历 sku。
/// </summary>
private static (long? Min, long? Max) GetPriceRange(JsonObject? priceNode, JsonObject root)
{
long? min = null;
long? max = null;
if (priceNode != null)
{
min = GetLongOrNull(priceNode, "min_group_price");
max = GetLongOrNull(priceNode, "max_group_price");
if (min.HasValue || max.HasValue)
return (min, max);
}
if (root["sku"] is JsonArray skuArray && skuArray.Count > 0)
{
foreach (var item in skuArray.OfType<JsonObject>())
{
var price = GetLongOrNull(item, "group_price") ?? GetLongOrNull(item, "normal_price");
if (price == null) continue;
min = min.HasValue ? Math.Min(min.Value, price.Value) : price;
max = max.HasValue ? Math.Max(max.Value, price.Value) : price;
}
if (min.HasValue || max.HasValue)
return (min, max);
}
return (null, null);
}
}
}
@@ -1,17 +0,0 @@
using FreeSql;
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Store.Modules.CloudProduct.Entities;
namespace Store.Modules.CloudProduct.Repositorys
{
/// <summary>
/// 商品仓储(对应 goods 表)
/// </summary>
public class GoodsRepository : BaseRepository<GoodsEntity>, IScopedDependency
{
public GoodsRepository([FromKeyedServices("CloudProductConnection")] IFreeSql fsql) : base(fsql)
{
}
}
}
@@ -1,17 +0,0 @@
using FreeSql;
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Store.Modules.CloudProduct.Entities;
namespace Store.Modules.CloudProduct.Repositorys
{
/// <summary>
/// 用户收藏分组仓储(对应 user_goods_favorites_group 表)
/// </summary>
public class UserGoodsFavoriteGroupRepository : BaseRepository<UserGoodsFavoriteGroupEntity>, IScopedDependency
{
public UserGoodsFavoriteGroupRepository([FromKeyedServices("CloudProductConnection")] IFreeSql fsql) : base(fsql)
{
}
}
}
@@ -1,17 +0,0 @@
using FreeSql;
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Store.Modules.CloudProduct.Entities;
namespace Store.Modules.CloudProduct.Repositorys
{
/// <summary>
/// 用户收藏商品仓储(对应 user_goods_favorites 表)
/// </summary>
public class UserGoodsFavoriteRepository : BaseRepository<UserGoodsFavoriteEntity>, IScopedDependency
{
public UserGoodsFavoriteRepository([FromKeyedServices("CloudProductConnection")] IFreeSql fsql) : base(fsql)
{
}
}
}
@@ -1,32 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Store.Modules.CloudProduct.Services
{
/// <summary>
/// Hangfire 定时任务:把数据库中未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引。
/// </summary>
public class GoodsIndexJob
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<GoodsIndexJob> _logger;
public GoodsIndexJob(IServiceScopeFactory scopeFactory, IConfiguration configuration,ILogger<GoodsIndexJob> logger)
{
_scopeFactory = scopeFactory;
_configuration = configuration;
_logger = logger;
}
public async Task ProcessPendingAsync()
{
var batchSize = _configuration.GetValue<int?>("Indexing:BatchSize") ?? 1000;
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<GoodsService>();
var count = await service.IndexPendingGoodsAsync(batchSize);
_logger.LogInformation($"[Hangfire] 商品分词定时任务完成,本次同步 {count} 条");
}
}
}
@@ -1,402 +0,0 @@
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Repositorys;
namespace Store.Modules.CloudProduct.Services
{
public interface IGoodsService : IScopedDependency
{
Task<int> DeleteGoodsAsync(IEnumerable<long> ids);
Task<List<GoodsEntity>> GetGoodsByGoodsIdsAsync(IEnumerable<long> goodsIds);
Task<GoodsImportResult> ImportGoodsAsync(IEnumerable<GoodsImportItem> items);
Task<int> IndexPendingGoodsAsync(int batchSize);
Task<long> RebuildSearchIndexAsync(IEnumerable<long>? ids = null);
Task<(List<GoodsEntity> List, long Total)> SearchGoodsAsync(GoodsSearchRequest request);
Task<int> UpdateThumbUrlAsync(IEnumerable<GoodsEntity> goodsList);
}
internal class GoodsService : IGoodsService
{
private readonly GoodsRepository _repository;
private readonly IOpenSearchService _searchService;
private readonly IRawDataStorage _rawDataStorage;
private readonly IConfiguration _configuration;
public GoodsService(GoodsRepository repository, IOpenSearchService searchService, IRawDataStorage rawDataStorage, IConfiguration configuration)
{
_repository = repository;
_searchService = searchService;
_rawDataStorage = rawDataStorage;
_configuration = configuration;
}
public async Task<(List<GoodsEntity> List, long Total)> SearchGoodsAsync(GoodsSearchRequest request)
{
// 1. 从搜索引擎获取标题匹配的商品
var searchResult = await _searchService.SearchByTitleAsync(request);
var goodsList = searchResult.Documents.Select(a => new GoodsEntity
{
Platform = a.Platform,
GoodsId = a.Id,
Title = a.Title,
CatId1 = a.CatId1,
CatId2 = a.CatId2,
CatId3 = a.CatId3,
CatId4 = a.CatId4,
MinPrice = a.MinPrice,
MaxPrice = 0,
SoldQuantity = a.SoldQuantity,
ThumbUrl = a.ThumbUrl,
}).ToList();
return (goodsList, searchResult.Total);
//// 2. 构建数据库查询(只查正常状态,排除软删除)
//var query = _repository.Select.Where(x => x.Status == 0);
//// 如果有标题搜索,先用ID过滤
//if (goodsIds.Any())
//{
// query = query.Where(x => goodsIds.Contains(x.GoodsId));
//}
//// 3. 价格区间查询
//if (request.MinPrice.HasValue)
//{
// query = query.Where(x => x.MinPrice >= request.MinPrice.Value);
//}
//if (request.MaxPrice.HasValue)
//{
// query = query.Where(x => x.MaxPrice <= request.MaxPrice.Value);
//}
//// 4. 类目查询
//if (request.CatId1.HasValue)
//{
// query = query.Where(x => x.CatId1 == request.CatId1);
//}
//if (request.CatId2.HasValue)
//{
// query = query.Where(x => x.CatId2 == request.CatId2);
//}
//if (request.CatId3.HasValue)
//{
// query = query.Where(x => x.CatId3 == request.CatId3);
//}
//if (request.CatId4.HasValue)
//{
// query = query.Where(x => x.CatId4 == request.CatId4);
//}
//// 5. 销量区间查询
//if (request.MinSoldQuantity.HasValue)
//{
// query = query.Where(x => x.SoldQuantity >= request.MinSoldQuantity.Value);
//}
//if (request.MaxSoldQuantity.HasValue)
//{
// query = query.Where(x => x.SoldQuantity <= request.MaxSoldQuantity.Value);
//}
//// 6. 平台过滤
//if (request.Platform.HasValue)
//{
// query = query.Where(x => x.Platform == request.Platform);
//}
//// 8. 获取总数
//total = await query.CountAsync();
//// 9. 排序
//if (!string.IsNullOrEmpty(request.SortBy))
//{
// var isAsc = request.SortOrder?.ToLower() == "asc";
// query = query.OrderByPropertyName(request.SortBy, isAsc);
//}
//else
//{
// query = query.OrderByDescending(x => x.CreatedTime);
//}
//// 10. 分页查询
//var list = await query
// //.Skip((request.PageIndex - 1) * request.PageSize)
// //.Limit(request.PageSize)
// .ToListAsync();
////内存排序
//var orderMap = goodsIds.Select((id, idx) => new { id, idx })
// .ToDictionary(x => x.id, x => x.idx);
//list = list.OrderBy(x => orderMap.TryGetValue(x.GoodsId, out var idx) ? idx : int.MaxValue)
// .ToList();
//return (list, total);
}
/// <summary>
/// 批量导入商品:先落库(按平台 + 商品编号去重)+ 保存原始数据,
/// 然后根据配置决定“立即分词”还是“标记未同步交给后台任务分词”。
/// </summary>
public async Task<GoodsImportResult> ImportGoodsAsync(IEnumerable<GoodsImportItem> items)
{
var result = new GoodsImportResult();
if (items == null)
return result;
var indexImmediately = IsImmediateIndexing();
var documents = new List<GoodsSearchDocument>();
var indexedIds = new List<long>();
foreach (var item in items)
{
var goods = item?.Goods;
if (goods == null)
{
result.FailedCount++;
result.Errors.Add("商品数据为空");
continue;
}
var goodsId = goods.GoodsId;
if (goodsId == 0)
{
result.FailedCount++;
result.Errors.Add("商品编号(GoodsId)不能为空");
continue;
}
try
{
// 导入时统一先标记“未同步”;立即分词成功后再置为“已同步”,
// 后台模式下保持“未同步”,由 Hangfire 定时任务分词。
goods.IsSegmented = 0;
var saved = await UpsertGoodsAsync(goods);
var rawData = item?.RawData ?? string.Empty;
if (!string.IsNullOrWhiteSpace(rawData))
{
await _rawDataStorage.SaveAsync(saved.GoodsId, rawData);
}
if (indexImmediately)
{
documents.Add(new GoodsSearchDocument
{
Platform = saved.Platform,
Id = saved.GoodsId,
Title = saved.Title ?? string.Empty,
ThumbUrl = saved.ThumbUrl,
CatId1 = saved.CatId1,
CatId2 = saved.CatId2,
CatId3 = saved.CatId3,
CatId4 = saved.CatId4,
MinPrice = saved.MinPrice,
SoldQuantity = saved.SoldQuantity,
});
indexedIds.Add(saved.GoodsId);
}
result.SuccessCount++;
}
catch (Exception ex)
{
result.FailedCount++;
result.Errors.Add($"GoodsId={goodsId}: {ex.Message}");
}
}
if (documents.Count > 0)
{
try
{
await _searchService.BulkIndexGoodsAsync(documents);
await MarkIndexedAsync(indexedIds);
}
catch (Exception ex)
{
// 写入失败时保持“未同步”,由后台定时任务重试
result.FailedCount += documents.Count;
result.SuccessCount -= documents.Count;
result.Errors.Add("写入 OpenSearch 失败: " + ex.Message);
}
}
return result;
}
/// <summary>
/// 后台任务:批量处理未同步(IsSegmented=0)的商品,写入 OpenSearch 后标记为已同步。
/// </summary>
public async Task<int> IndexPendingGoodsAsync(int batchSize)
{
if (batchSize <= 0)
batchSize = 200;
var pending = await _repository.Select
.Where(x => x.IsSegmented == 0 && x.Status == 0)
.OrderBy(x => x.GoodsId)
.Limit(batchSize)
.ToListAsync();
if (pending.Count == 0)
return 0;
var documents = pending
.Select(x => new GoodsSearchDocument
{
Platform = x.Platform,
Id = x.GoodsId,
Title = x.Title ?? string.Empty,
ThumbUrl = x.ThumbUrl,
CatId1 = x.CatId1,
CatId2 = x.CatId2,
CatId3 = x.CatId3,
CatId4 = x.CatId4,
MinPrice = x.MinPrice,
SoldQuantity = x.SoldQuantity,
})
.ToList();
await _searchService.BulkIndexGoodsAsync(documents);
await MarkIndexedAsync(pending.Select(x => x.GoodsId));
return pending.Count;
}
/// <summary>
/// 按平台 + 商品编号查找已存在记录,存在则更新,不存在则新增。
/// </summary>
private async Task<GoodsEntity> UpsertGoodsAsync(GoodsEntity goods)
{
var existing = await _repository.Select
.Where(x => x.Platform == goods.Platform && x.GoodsId == goods.GoodsId)
.FirstAsync();
if (existing != null)
{
goods.Id = existing.Id;
goods.GoodsId = existing.GoodsId;
goods.CreatedTime = existing.CreatedTime;
goods.UpdatedTime = DateTime.Now;
await _repository.UpdateAsync(goods);
return goods;
}
goods.CreatedTime = DateTime.Now;
goods.UpdatedTime = DateTime.Now;
return await _repository.InsertAsync(goods);
}
/// <summary>
/// 根据商品编号(GoodsId)列表读取商品。
/// </summary>
public async Task<List<GoodsEntity>> GetGoodsByGoodsIdsAsync(IEnumerable<long> goodsIds)
{
var idList = goodsIds?.ToList();
if (idList == null || idList.Count == 0)
return new List<GoodsEntity>();
return await _repository.Select
.Where(x => idList.Contains(x.GoodsId))
.ToListAsync();
}
/// <summary>
/// 重新从数据库读取指定商品,覆盖 OpenSearch 中已有的分词并重新分词。
/// ids 为空时重建全部商品。重建成功的记录标记为已同步。
/// </summary>
public async Task<long> RebuildSearchIndexAsync(IEnumerable<long>? ids = null)
{
List<GoodsEntity> goodsList;
var idList = ids?.ToList();
if (idList == null || idList.Count == 0)
{
goodsList = await _repository.Select.ToListAsync();
}
else
{
goodsList = await _repository.Select
.Where(x => idList.Contains(x.GoodsId))
.ToListAsync();
}
var count = await _searchService.RebuildIndexAsync(goodsList);
await MarkIndexedAsync(goodsList.Where(x => x.GoodsId > 0).Select(x => x.GoodsId));
return count;
}
/// <summary>
/// 删除商品:按配置决定软删除(Status=1)还是硬删除(物理删除),
/// 两种方式都会删除 OpenSearch 中对应的文档。
/// </summary>
public async Task<int> DeleteGoodsAsync(IEnumerable<long> ids)
{
var idList = ids?.Distinct().ToList();
if (idList == null || idList.Count == 0)
return 0;
if (IsSoftDelete())
{
// 软删除:仅用 Status 标记(0正常 1已删除)
await _repository.Orm.Update<GoodsEntity>()
.Set(x => x.Status, (byte)1)
.Where(x => idList.Contains(x.GoodsId))
.ExecuteAffrowsAsync();
}
else
{
// 硬删除:直接删除数据库记录
await _repository.Orm.Delete<GoodsEntity>()
.Where(x => idList.Contains(x.GoodsId))
.ExecuteAffrowsAsync();
}
// 无论软删除还是硬删除,都要删除 OpenSearch 中的对应文档
await _searchService.DeleteDocumentsAsync(idList);
return idList.Count;
}
/// <summary>
/// 批量更新商品缩略图。
/// </summary>
public async Task<int> UpdateThumbUrlAsync(IEnumerable<GoodsEntity> goodsList)
{
//string sql = _repository.Orm.Update<GoodsEntity>().SetSource(goodsList, a => a.GoodsId).UpdateColumns(a => new { a.ThumbUrl }).ToSql();
return await _repository.Orm.Update<GoodsEntity>()
.SetSource(goodsList, a => a.GoodsId)
.UpdateColumns(a => new { a.ThumbUrl })
.ExecuteAffrowsAsync();
}
/// <summary>
/// 是否导入时立即分词(Indexing:Mode = Immediate)。
/// </summary>
private bool IsImmediateIndexing()
{
var mode = _configuration.GetValue<string>("Indexing:Mode");
return string.Equals(mode, "Immediate", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 是否软删除(Delete:SoftDelete,默认 true)。
/// </summary>
private bool IsSoftDelete()
{
return _configuration.GetValue<bool?>("Delete:SoftDelete") ?? true;
}
private async Task MarkIndexedAsync(IEnumerable<long> ids)
{
var idList = ids?.ToList();
if (idList == null || idList.Count == 0)
return;
await _repository.Orm.Update<GoodsEntity>()
.Set(x => x.IsSegmented, (byte)1)
.Where(x => idList.Contains(x.GoodsId))
.ExecuteAffrowsAsync();
}
}
}
@@ -1,545 +0,0 @@
using System.Diagnostics;
using MAX.Core.DependencyInjection;
using MAX.Framework.Commons;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using OpenSearch.Client;
using OpenSearch.Net;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Enums;
namespace Store.Modules.CloudProduct.Services
{
public interface IOpenSearchService : ISingletonDependency
{
Task AddDocumentAsync(GoodsSearchDocument document);
Task BulkIndexGoodsAsync(IEnumerable<GoodsSearchDocument> documents);
Task<bool> ClearIndexAsync();
Task<long> CountDocumentsAsync();
Task<bool> CreateIndexAsync();
Task DeleteDocumentsAsync(IEnumerable<long> ids);
Task DeleteIndexAsync();
Task<OpenSearchStatusDto> GetStatusAsync();
Task<bool> IndexExistsAsync();
Task<bool> IsIndexMappingHealthyAsync();
Task RefreshIndexAsync();
Task<long> RebuildIndexAsync(IEnumerable<GoodsEntity> goodsList);
Task<(long Total, List<GoodsSearchDocument> Documents)> SearchByTitleAsync(GoodsSearchRequest request, GoodsSearchSortBy sortBy = GoodsSearchSortBy.Relevance);
}
internal class OpenSearchService : IOpenSearchService
{
private readonly string _nodeUrl;
private readonly string _indexName;
private readonly IOpenSearchClient _client = null!;
// 查询类操作(search/count/status/ping)统一超时
private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(5);
// 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长,可挪到配置文件)
private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30);
private readonly ILogger<OpenSearchService> _logger;
public OpenSearchService(IConfiguration configuration,ILogger<OpenSearchService> logger)
{
var config = configuration.GetSection("Search");
_nodeUrl = config.GetValue<string>("Url") ?? "";
_indexName = config.GetValue<string>("IndexName") ?? "";
_client = CreateClient();
_logger = logger;
}
private IOpenSearchClient CreateClient()
{
var pool = new SingleNodeConnectionPool(new Uri(_nodeUrl));
var settings = new ConnectionSettings(pool)
.DefaultIndex(_indexName)
.DefaultMappingFor<GoodsSearchDocument>(m => m.IndexName(_indexName))
.RequestTimeout(QueryTimeout)
.MaxRetryTimeout(QueryTimeout)
.PrettyJson()
.DisableDirectStreaming(); // 便于调试打印请求/响应报文
return new OpenSearchClient(settings);
}
/// <summary>
/// ============ 1. 创建索引 ============
/// </summary>
public async Task<bool> CreateIndexAsync()
{
var existsResponse = await _client.Indices.ExistsAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (existsResponse.Exists)
{
if (await IsIndexMappingHealthyAsync())
{
Console.WriteLine($"索引 [{_indexName}] 已存在且映射正确,无需重复创建。");
return true;
}
Console.WriteLine($"索引 [{_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)
{
Console.WriteLine("IK 分词插件不可用,已回退到 standard 分析器,中文分词将失效,请检查集群是否安装 analysis-ik 插件。");
createResponse = await TryCreateIndexAsync(useIk: false);
}
if (createResponse.IsValid)
{
Console.WriteLine($"索引 [{_indexName}] 创建成功。");
return true;
}
else
{
throw new AppException("索引创建失败: " + createResponse.DebugInformation);
}
}
public async Task<bool> IsIndexMappingHealthyAsync()
{
var mappingStatus = await GetIndexMappingStatusAsync();
return mappingStatus.Healthy;
}
private async Task<(bool Healthy, string? IndexAnalyzer, string? SearchAnalyzer)> GetIndexMappingStatusAsync()
{
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, null, null);
}
var mapping = indexState.Mappings;
if (mapping?.Properties == null
|| !mapping.Properties.TryGetValue(new PropertyName("title"), out var property)
|| property is not ITextProperty textProperty)
{
return (false, null, null);
}
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, indexAnalyzer, searchAnalyzer);
}
private async Task<CreateIndexResponse> TryCreateIndexAsync(bool useIk)
{
var indexAnalyzer = useIk ? "ik_max_word" : "standard";
var searchAnalyzer = useIk ? "ik_smart" : "standard";
return await _client.Indices.CreateAsync(_indexName, c => c
.Settings(s => s
.NumberOfShards(1)
.NumberOfReplicas(0)
)
.Map<GoodsSearchDocument>(m => m
.Properties(p => p
.Number(n => n.Name(x => x.Id).Type(NumberType.Long))
.Text(t => t
.Name(x => x.Title)
.Analyzer(indexAnalyzer)
.SearchAnalyzer(searchAnalyzer)
)
.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))
)
)
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout))
);
}
/// <summary>
/// ============ 2. 删除索引 ============
/// </summary>
public async Task DeleteIndexAsync()
{
var response = await _client.Indices.DeleteAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (!response.IsValid)
{
throw new AppException("删除失败: " + response.DebugInformation);
}
}
public async Task<bool> IndexExistsAsync()
{
var response = await _client.Indices.ExistsAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
return response.Exists;
}
public async Task RefreshIndexAsync()
{
var response = await _client.Indices.RefreshAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (!response.IsValid)
{
throw new AppException("刷新索引失败: " + response.DebugInformation);
}
}
/// <summary>
/// ============ 3.1 批量写入商品搜索文档(存在则覆盖) ============
/// </summary>
public async Task BulkIndexGoodsAsync(IEnumerable<GoodsSearchDocument> documents)
{
if (documents == null) throw new ArgumentNullException(nameof(documents));
var list = documents.ToList();
if (list.Count == 0) return;
// 确保索引已按 IK 分词映射创建,否则中文分词无效
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("写入 OpenSearch 失败: " + bulkResponse.DebugInformation);
}
// 刷新索引,保证数据立即可搜索(仅用于演示,生产环境不建议频繁 Refresh)
await RefreshIndexAsync();
}
/// <summary>
/// ============ 3.2 根据数据库商品重建搜索索引 ============
/// </summary>
public async Task<long> RebuildIndexAsync(IEnumerable<GoodsEntity> goodsList)
{
if (goodsList == null) throw new ArgumentNullException(nameof(goodsList));
var documents = goodsList
.Where(x => x != null && x.GoodsId > 0)
.Select(x => new GoodsSearchDocument
{
Id = x.GoodsId,
Title = x.Title ?? string.Empty,
CatId1 = x.CatId1,
CatId2 = x.CatId2,
CatId3 = x.CatId3,
CatId4 = x.CatId4,
MinPrice = x.MinPrice,
SoldQuantity = x.SoldQuantity
})
.ToList();
if (documents.Count == 0) return 0;
await BulkIndexGoodsAsync(documents);
return documents.Count;
}
/// <summary>
/// ============ 3.3 按商品编号批量删除 OpenSearch 文档(软/硬删除共用) ============
/// </summary>
public async Task DeleteDocumentsAsync(IEnumerable<long> ids)
{
var list = ids?.Distinct().ToList() ?? new List<long>();
if (list.Count == 0) return;
var exists = await _client.Indices.ExistsAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!exists.Exists)
{
return;
}
var response = await _client.BulkAsync(b =>
{
foreach (var id in list)
{
b.Delete<GoodsSearchDocument>(d => d.Index(_indexName).Id(id));
}
b.RequestConfiguration(r => r.RequestTimeout(AdminTimeout));
return b;
});
if (!response.IsValid)
{
throw new AppException("删除 OpenSearch 文档失败: " + response.DebugInformation);
}
await RefreshIndexAsync();
}
/// <summary>
/// ============ 4. 手动新增一条搜索文档 ============
/// </summary>
public async Task AddDocumentAsync(GoodsSearchDocument document)
{
if (document == null) throw new ArgumentNullException(nameof(document));
var response = await _client.IndexAsync(document, i => i
.Index(_indexName)
.Id(document.Id)
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout))
);
if (response.IsValid)
{
await RefreshIndexAsync();
}
else
{
throw new AppException("新增失败: " + response.DebugInformation);
}
}
/// <summary>
/// ============ 5. 分词搜索(分页 + 类目/价格/销量/平台过滤 + 排序) ============
/// </summary>
/// <param name="request">搜索条件(关键词、类目、价格、销量、平台、分页)</param>
/// <param name="sortBy">排序方式</param>
public async Task<(long Total, List<GoodsSearchDocument> Documents)> SearchByTitleAsync(GoodsSearchRequest request,
GoodsSearchSortBy sortBy = GoodsSearchSortBy.Relevance)
{
var keyword = request.Title;
int? catId1 = request.CatId1;
int? catId2 = request.CatId2;
int? catId3 = request.CatId3;
int? catId4 = request.CatId4;
long? minPrice = request.MinPrice;
long? maxPrice = request.MaxPrice;
int? minSoldQuantity = request.MinSoldQuantity;
int? maxSoldQuantity = request.MaxSoldQuantity;
int? platform = request.Platform;
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<GoodsSearchDocument> searchResponse;
try
{
searchResponse = await _client.SearchAsync<GoodsSearchDocument>(s => s
.Index(_indexName)
.Query(q => q
.Bool(b =>
{
if (string.IsNullOrWhiteSpace(keyword) == false)
{
b.Must(m => m.Match(mm => mm.Field(f => f.Title).Query(keyword)));
}
var filters = new List<Func<QueryContainerDescriptor<GoodsSearchDocument>, QueryContainer>>();
if (platform.HasValue && platform!.Value > 0) filters.Add(f => f.Term(t => t.Field(x => x.Platform).Value(platform.Value)));
if (catId1.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId1).Value(catId1.Value)));
if (catId2.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId2).Value(catId2.Value)));
if (catId3.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId3).Value(catId3.Value)));
if (catId4.HasValue) filters.Add(f => f.Term(t => t.Field(x => x.CatId4).Value(catId4.Value)));
if (minPrice.HasValue || maxPrice.HasValue)
{
filters.Add(f => f.Range(r =>
{
var range = r.Field(x => x.MinPrice);
if (minPrice.HasValue) range = range.GreaterThanOrEquals(minPrice.Value);
if (maxPrice.HasValue) range = range.LessThanOrEquals(maxPrice.Value);
return range;
}));
}
if (minSoldQuantity.HasValue || maxSoldQuantity.HasValue)
{
filters.Add(f => f.Range(r =>
{
var range = r.Field(x => x.SoldQuantity);
if (minSoldQuantity.HasValue) range = range.GreaterThanOrEquals(minSoldQuantity.Value);
if (maxSoldQuantity.HasValue) range = range.LessThanOrEquals(maxSoldQuantity.Value);
return range;
}));
}
if (filters.Count > 0) b.Filter(filters.ToArray());
return b;
})
)
.Sort(sort => sortBy switch
{
GoodsSearchSortBy.PriceAsc => sort.Ascending(f => f.MinPrice),
GoodsSearchSortBy.PriceDesc => sort.Descending(f => f.MinPrice),
GoodsSearchSortBy.SoldDesc => sort.Descending(f => f.SoldQuantity),
_ => sort.Descending(SortSpecialField.Score)
})
.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());
}
/// <summary>
/// ============ 6. 统计文档数 ============
/// </summary>
public async Task<long> CountDocumentsAsync()
{
var countResponse = await _client.CountAsync<GoodsSearchDocument>(c => c
.Index(_indexName)
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout))
);
if (countResponse.IsValid)
{
Console.WriteLine($"索引 [{_indexName}] 当前文档总数: {countResponse.Count}");
return countResponse.Count;
}
else
{
Console.WriteLine("统计失败: " + countResponse.DebugInformation);
throw new AppException(countResponse.DebugInformation);
}
}
/// <summary>
/// ============ 7. 查询 OpenSearch 状态 ============
/// </summary>
public async Task<OpenSearchStatusDto> GetStatusAsync()
{
var stopwatch = Stopwatch.StartNew();
var status = new OpenSearchStatusDto
{
NodeUrl = _nodeUrl,
IndexName = _indexName,
};
try
{
var ping = await _client.PingAsync(p => p
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
status.Connected = ping.IsValid;
if (!ping.IsValid)
{
status.Message = ping.DebugInformation;
_logger.LogError(ping.DebugInformation);
return status;
}
_logger.LogInformation("OpenSerach 已连接");
var clusterHealth = await _client.Cluster.HealthAsync(Indices.All, h => h
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!clusterHealth.IsValid)
{
status.Message = clusterHealth.DebugInformation;
return status;
}
status.ClusterName = clusterHealth.ClusterName;
status.ClusterStatus = clusterHealth.Status.ToString().ToLowerInvariant();
status.NumberOfNodes = clusterHealth.NumberOfNodes;
status.NumberOfDataNodes = clusterHealth.NumberOfDataNodes;
status.ActivePrimaryShards = clusterHealth.ActivePrimaryShards;
status.ActiveShards = clusterHealth.ActiveShards;
status.RelocatingShards = clusterHealth.RelocatingShards;
status.InitializingShards = clusterHealth.InitializingShards;
status.UnassignedShards = clusterHealth.UnassignedShards;
status.IndexExists = await IndexExistsAsync();
if (status.IndexExists)
{
var mappingStatus = await GetIndexMappingStatusAsync();
status.MappingHealthy = mappingStatus.Healthy;
status.IndexAnalyzer = mappingStatus.IndexAnalyzer;
status.SearchAnalyzer = mappingStatus.SearchAnalyzer;
var count = await _client.CountAsync<GoodsSearchDocument>(c => c
.Index(_indexName)
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
status.DocumentCount = count.IsValid ? count.Count : -1;
if (!count.IsValid)
{
status.Message = count.DebugInformation;
}
}
else
{
status.Message = $"索引 [{_indexName}] 不存在";
}
status.Healthy = status.IndexExists
&& status.MappingHealthy
&& status.ClusterStatus != "red"
&& status.DocumentCount >= 0;
}
catch (Exception ex)
{
status.Healthy = false;
status.Message = ex.Message;
}
finally
{
stopwatch.Stop();
status.ElapsedMilliseconds = stopwatch.ElapsedMilliseconds;
}
return status;
}
/// <summary>
/// 清空索引:删除后按统一映射重建,重建期间该索引不可查(由后台定时任务负责回填数据,接受短暂空窗)
/// </summary>
public async Task<bool> ClearIndexAsync()
{
var existsResponse = await _client.Indices.ExistsAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!existsResponse.Exists)
{
Console.WriteLine($"索引 {_indexName} 不存在");
return false;
}
var deleteResponse = await _client.Indices.DeleteAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (!deleteResponse.IsValid)
{
Console.WriteLine($"删除索引 {_indexName} 失败: {deleteResponse.DebugInformation}");
return false;
}
Console.WriteLine($"索引 {_indexName} 已删除");
return await CreateIndexAsync();
}
}
}
@@ -1,82 +0,0 @@
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.Configuration;
using System.IO.Compression;
using System.Text;
namespace Store.Modules.CloudProduct.Services
{
/// <summary>
/// 商品原始数据文件存储:按 goodsId % 100 分目录,文件名即商品ID,GZip 压缩保存。
/// </summary>
public interface IRawDataStorage : IScopedDependency
{
/// <summary>
/// 保存原始数据,返回保存后的完整路径。
/// </summary>
Task<string> SaveAsync(long goodsId, string rawData);
/// <summary>
/// 读取并解压原始数据;文件不存在时返回 null。
/// </summary>
Task<string?> ReadAsync(long goodsId);
/// <summary>
/// 判断指定商品的原始数据文件是否存在。
/// </summary>
bool Exists(long goodsId);
}
internal class RawDataStorage : IRawDataStorage
{
private readonly string _rootPath;
public RawDataStorage(IConfiguration configuration)
{
var configured = configuration.GetValue<string>("RawData:Path");
if (string.IsNullOrWhiteSpace(configured))
configured = "RawData";
_rootPath = Path.GetFullPath(configured);
}
/// <inheritdoc />
public async Task<string> SaveAsync(long goodsId, string rawData)
{
var filePath = GetFilePath(goodsId);
var dir = Path.GetDirectoryName(filePath)!;
Directory.CreateDirectory(dir);
await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
await using var gzip = new GZipStream(fileStream, CompressionLevel.Fastest);
await using var writer = new StreamWriter(gzip, new UTF8Encoding(false));
await writer.WriteAsync(rawData);
return filePath;
}
/// <inheritdoc />
public async Task<string?> ReadAsync(long goodsId)
{
var filePath = GetFilePath(goodsId);
if (!File.Exists(filePath))
return null;
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var gzip = new GZipStream(fileStream, CompressionMode.Decompress);
using var reader = new StreamReader(gzip, new UTF8Encoding(false));
return await reader.ReadToEndAsync();
}
/// <inheritdoc />
public bool Exists(long goodsId)
{
return File.Exists(GetFilePath(goodsId));
}
private string GetFilePath(long goodsId)
{
var subDir = (goodsId % 100).ToString();
return Path.Combine(_rootPath, subDir, goodsId.ToString());
}
}
}
@@ -1,420 +0,0 @@
using MAX.Core.DependencyInjection;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using OpenSearch.Client;
using OpenSearch.Net;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Enums;
namespace Store.Modules.CloudProduct.Services
{
/// <summary>
/// 用户收藏商品索引服务:负责收藏索引(Search:FavoritesIndexName)的
/// 创建、写入、删除、重建与检索,索引文档由 user_goods_favorites 关联 goods 补全而来。
/// </summary>
public interface IUserFavoritesIndexService : ISingletonDependency
{
/// <summary>检查索引是否存在</summary>
Task<bool> IndexExistsAsync();
/// <summary>检查索引映射(分析器)是否符合当前服务要求</summary>
Task<bool> IsIndexMappingHealthyAsync();
/// <summary>创建索引(含 IK 分词映射;已存在且映射正常时跳过)</summary>
Task<bool> CreateIndexAsync();
/// <summary>删除并重建索引(映射统一,重建期间该索引不可查)</summary>
Task<bool> ClearIndexAsync();
/// <summary>批量写入收藏文档(存在则覆盖)</summary>
Task IndexDocumentsAsync(IEnumerable<UserFavoritesSearchDocument> documents);
/// <summary>按文档Id批量删除收藏文档</summary>
Task DeleteDocumentsAsync(IEnumerable<string> documentIds);
/// <summary>统计索引文档数</summary>
Task<long> CountDocumentsAsync();
/// <summary>按用户检索收藏商品(标题/销量/价格/类目/分组)</summary>
Task<(long Total, List<UserFavoritesSearchDocument> Documents)> SearchAsync(UserFavoritesSearchRequest request, UserFavoritesSortBy sortBy);
}
internal class UserFavoritesIndexService : IUserFavoritesIndexService
{
private readonly string _nodeUrl;
private readonly string _indexName;
private readonly IOpenSearchClient _client = null!;
private readonly ILogger<UserFavoritesIndexService> _logger;
// 查询类操作(search/count/exists)统一超时
private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(5);
// 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长)
private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30);
public UserFavoritesIndexService(IConfiguration configuration, ILogger<UserFavoritesIndexService> logger)
{
var config = configuration.GetSection("Search");
_nodeUrl = config.GetValue<string>("Url") ?? "";
_indexName = config.GetValue<string>("FavoritesIndexName") ?? "";
_logger = logger;
_client = CreateClient();
}
private IOpenSearchClient CreateClient()
{
var pool = new SingleNodeConnectionPool(new Uri(_nodeUrl));
var settings = new ConnectionSettings(pool)
.DefaultIndex(_indexName)
.DefaultMappingFor<UserFavoritesSearchDocument>(m => m.IndexName(_indexName))
.RequestTimeout(QueryTimeout)
.MaxRetryTimeout(QueryTimeout)
.PrettyJson()
.DisableDirectStreaming();
return new OpenSearchClient(settings);
}
/// <summary>
/// ============ 1. 索引是否存在 ============
/// </summary>
public async Task<bool> IndexExistsAsync()
{
var response = await _client.Indices.ExistsAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
return response.Exists;
}
/// <summary>
/// ============ 2. 索引映射是否健康 ============
/// </summary>
public async Task<bool> IsIndexMappingHealthyAsync()
{
var getResponse = await _client.Indices.GetAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!getResponse.IsValid)
{
throw new AppException("读取收藏索引映射失败: " + getResponse.DebugInformation);
}
if (!getResponse.Indices.TryGetValue(_indexName, out var indexState))
{
return false;
}
var mapping = indexState.Mappings;
if (mapping?.Properties == null
|| !mapping.Properties.TryGetValue(new PropertyName("title"), out var property)
|| property is not ITextProperty textProperty)
{
return false;
}
var indexAnalyzer = textProperty.Analyzer;
var searchAnalyzer = textProperty.SearchAnalyzer;
var usesIk = indexAnalyzer == "ik_max_word" && searchAnalyzer == "ik_smart";
var usesStandard = indexAnalyzer == "standard" && searchAnalyzer == "standard";
return usesIk || usesStandard;
}
/// <summary>
/// ============ 3. 创建索引 ============
/// </summary>
public async Task<bool> CreateIndexAsync()
{
var existsResponse = await _client.Indices.ExistsAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (existsResponse.Exists)
{
if (await IsIndexMappingHealthyAsync())
{
return true;
}
_logger.LogWarning("收藏索引 [{Index}] 的映射不完整,正在重建...", _indexName);
var deleteResponse = await _client.Indices.DeleteAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (!deleteResponse.IsValid)
{
throw new AppException("删除损坏的收藏索引失败: " + deleteResponse.DebugInformation);
}
}
// 优先尝试使用 IK 中文分词(需集群安装 analysis-ik 插件)
var createResponse = await TryCreateIndexAsync(useIk: true);
if (!createResponse.IsValid)
{
_logger.LogWarning("IK 分词插件不可用,收藏索引已回退到 standard 分析器,中文分词将失效。");
createResponse = await TryCreateIndexAsync(useIk: false);
}
if (!createResponse.IsValid)
{
throw new AppException("收藏索引创建失败: " + createResponse.DebugInformation);
}
_logger.LogInformation("收藏索引 [{Index}] 创建成功。", _indexName);
return true;
}
private async Task<CreateIndexResponse> TryCreateIndexAsync(bool useIk)
{
var indexAnalyzer = useIk ? "ik_max_word" : "standard";
var searchAnalyzer = useIk ? "ik_smart" : "standard";
return await _client.Indices.CreateAsync(_indexName, c => c
.Settings(s => s
.NumberOfShards(1)
.NumberOfReplicas(0)
)
.Map<UserFavoritesSearchDocument>(m => m
.Properties(p => p
.Keyword(k => k.Name(x => x.Id))
.Number(n => n.Name(x => x.FavId).Type(NumberType.Long))
.Keyword(k => k.Name(x => x.UserName))
.Number(n => n.Name(x => x.GroupId).Type(NumberType.Integer))
.Keyword(k => k.Name(x => x.GroupName))
.Number(n => n.Name(x => x.GoodsId).Type(NumberType.Long))
.Number(n => n.Name(x => x.Platform).Type(NumberType.Byte))
.Text(t => t
.Name(x => x.Title)
.Analyzer(indexAnalyzer)
.SearchAnalyzer(searchAnalyzer)
)
.Text(t => t.Name(x => x.ThumbUrl))
.Number(n => n.Name(x => x.CatId1).Type(NumberType.Integer))
.Number(n => n.Name(x => x.CatId2).Type(NumberType.Integer))
.Number(n => n.Name(x => x.CatId3).Type(NumberType.Integer))
.Number(n => n.Name(x => x.CatId4).Type(NumberType.Integer))
.Number(n => n.Name(x => x.MinPrice).Type(NumberType.Long))
.Number(n => n.Name(x => x.SoldQuantity).Type(NumberType.Integer))
.Date(d => d.Name(x => x.CreatedAt))
)
)
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout))
);
}
/// <summary>
/// ============ 4. 删除并重建索引 ============
/// </summary>
public async Task<bool> ClearIndexAsync()
{
var existsResponse = await _client.Indices.ExistsAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!existsResponse.Exists)
{
return await CreateIndexAsync();
}
var deleteResponse = await _client.Indices.DeleteAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (!deleteResponse.IsValid)
{
throw new AppException($"删除收藏索引 {_indexName} 失败: " + deleteResponse.DebugInformation);
}
_logger.LogInformation("收藏索引 [{Index}] 已删除,准备重建。", _indexName);
return await CreateIndexAsync();
}
private async Task RefreshIndexAsync()
{
var response = await _client.Indices.RefreshAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (!response.IsValid)
{
throw new AppException("刷新收藏索引失败: " + response.DebugInformation);
}
}
/// <summary>
/// ============ 5. 批量写入收藏文档(存在则覆盖) ============
/// </summary>
public async Task IndexDocumentsAsync(IEnumerable<UserFavoritesSearchDocument> documents)
{
if (documents == null) throw new ArgumentNullException(nameof(documents));
var list = documents.Where(x => x != null && string.IsNullOrWhiteSpace(x.Id) == false).ToList();
if (list.Count == 0) return;
await CreateIndexAsync();
var bulkResponse = await _client.BulkAsync(b => b
.Index(_indexName)
.IndexMany(list, (descriptor, doc) => descriptor.Id(doc.Id))
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout))
);
if (!bulkResponse.IsValid)
{
throw new AppException("批量写入收藏索引失败: " + bulkResponse.DebugInformation);
}
await RefreshIndexAsync();
}
/// <summary>
/// ============ 6. 按文档Id批量删除收藏文档 ============
/// </summary>
public async Task DeleteDocumentsAsync(IEnumerable<string> documentIds)
{
var list = documentIds?.Where(x => string.IsNullOrWhiteSpace(x) == false).Distinct().ToList() ?? new List<string>();
if (list.Count == 0) return;
if (await IndexExistsAsync() == false)
{
return;
}
var response = await _client.BulkAsync(b =>
{
foreach (var id in list)
{
b.Delete<UserFavoritesSearchDocument>(d => d.Index(_indexName).Id(id));
}
b.RequestConfiguration(r => r.RequestTimeout(AdminTimeout));
return b;
});
if (!response.IsValid)
{
throw new AppException("删除收藏索引文档失败: " + response.DebugInformation);
}
await RefreshIndexAsync();
}
/// <summary>
/// ============ 7. 统计文档数 ============
/// </summary>
public async Task<long> CountDocumentsAsync()
{
var countResponse = await _client.CountAsync<UserFavoritesSearchDocument>(c => c
.Index(_indexName)
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout))
);
if (!countResponse.IsValid)
{
throw new AppException("统计收藏索引失败: " + countResponse.DebugInformation);
}
return countResponse.Count;
}
/// <summary>
/// ============ 8. 检索用户收藏商品(标题/价格/销量/类目/分组 + 排序 + 分页) ============
/// </summary>
public async Task<(long Total, List<UserFavoritesSearchDocument> Documents)> SearchAsync(UserFavoritesSearchRequest request, UserFavoritesSortBy sortBy)
{
if (request == null) throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
var keyword = request.Title;
int? groupId = request.GroupId;
var groupName = request.GroupName;
int pageIndex = request.PageIndex;
int pageSize = request.PageSize;
if (pageIndex < 1) pageIndex = 1;
if (pageSize < 1) pageSize = 20;
if (pageSize > 2000) pageSize = 2000; // 避免深分页/单页过大拖垮性能
var from = (pageIndex - 1) * pageSize;
ISearchResponse<UserFavoritesSearchDocument> searchResponse;
try
{
searchResponse = await _client.SearchAsync<UserFavoritesSearchDocument>(s => s
.Index(_indexName)
.Query(q => q
.Bool(b =>
{
var filters = new List<Func<QueryContainerDescriptor<UserFavoritesSearchDocument>, QueryContainer>>();
// 只检索当前用户自己的收藏 —— 放进 filters,避免被后面的 Filter 覆盖
filters.Add(f => f.Term(t => t.Field(x => x.UserName).Value(request.UserName)));
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;
}));
}
// 统一一次性应用所有 filter,避免覆盖
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)
.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());
}
}
}
@@ -1,603 +0,0 @@
using MAX.Core.DependencyInjection;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using Microsoft.Extensions.Logging;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Enums;
using Store.Modules.CloudProduct.Repositorys;
namespace Store.Modules.CloudProduct.Services
{
/// <summary>
/// 用户收藏商品业务:收藏/取消收藏(数据库 + 收藏索引同步写入)、收藏分组维护、收藏状态查询、收藏检索与索引重建。
/// </summary>
public interface IUserFavoritesService : IScopedDependency
{
/// <summary>批量收藏商品,返回本次成功收藏的商品数量(已收藏的商品视为修改收藏分组)</summary>
Task<int> AddFavoritesAsync(AddUserFavoriteRequest request);
/// <summary>批量取消收藏,返回实际取消的条数</summary>
Task<int> RemoveFavoritesAsync(RemoveUserFavoritesRequest request);
/// <summary>创建收藏分组:创建成功返回 true,同名分组已存在返回 false</summary>
Task<bool> CreateGroupAsync(CreateUserFavoriteGroupRequest request);
/// <summary>查询用户的收藏分组(含默认分组)及分组下收藏数量</summary>
Task<List<UserFavoriteGroupDto>> GetGroupsAsync(string userName);
/// <summary>按 GoodsIds 顺序返回每个商品是否已被该用户收藏</summary>
Task<List<bool>> GetFavoriteStatusAsync(UserFavoriteStatusRequest request);
/// <summary>检索用户收藏的商品(标题/销量/价格/类目/分组)</summary>
Task<(List<UserFavoriteItem> List, long Total)> SearchFavoritesAsync(UserFavoritesSearchRequest request);
/// <summary>移除所有用户收藏索引并按数据库数据全量重建,返回重建的文档数</summary>
Task<long> RebuildSearchIndexAsync();
}
internal class UserFavoritesService : IUserFavoritesService
{
/// <summary>未归属任何分组的收藏所使用的分组名(对应 group_id = 0)</summary>
public const string DefaultGroupName = "默认分组";
/// <summary>重建索引时单次 bulk 的文档数,避免一次请求过大</summary>
private const int RebuildBatchSize = 2000;
private readonly UserGoodsFavoriteRepository _favoriteRepository;
private readonly UserGoodsFavoriteGroupRepository _groupRepository;
private readonly GoodsRepository _goodsRepository;
private readonly IUserFavoritesIndexService _indexService;
private readonly ILogger<UserFavoritesService> _logger;
public UserFavoritesService(
UserGoodsFavoriteRepository favoriteRepository,
UserGoodsFavoriteGroupRepository groupRepository,
GoodsRepository goodsRepository,
IUserFavoritesIndexService indexService,
ILogger<UserFavoritesService> logger)
{
_favoriteRepository = favoriteRepository;
_groupRepository = groupRepository;
_goodsRepository = goodsRepository;
_indexService = indexService;
_logger = logger;
}
/// <summary>
/// 批量收藏商品:先落库(user_goods_favorites),再同步写入收藏索引;
/// 商品不存在/已删除的编号会被跳过,返回本次成功收藏的商品数量。
/// 任意一步失败都会回滚本次已写入的数据库记录,保证“数据库中有收藏 = 索引中可搜索到”。
/// </summary>
public async Task<int> AddFavoritesAsync(AddUserFavoriteRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
var userName = request.UserName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(userName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
var goodsIds = request.GoodsIds?.Where(x => x > 0).Distinct().ToList() ?? new List<long>();
if (goodsIds.Count == 0)
{
throw new AppException("请提供要收藏的商品编号", ResultCode.ValidationError);
}
if (request.GroupId < 0)
{
throw new AppException("收藏分组无效", ResultCode.ValidationError);
}
// 1. 校验分组归属(分组ID > 0 时必须是该用户自己创建的分组)
if (request.GroupId > 0)
{
var group = await _groupRepository.Select
.Where(x => x.Id == request.GroupId && x.UserName == userName)
.FirstAsync();
if (group == null)
{
throw new AppException($"收藏分组不存在: {request.GroupId}", ResultCode.ValidationError);
}
}
var groupName = await ResolveGroupNameAsync(userName, request.GroupId);
// 2. 批量取商品快照(不存在/已删除的商品不收藏,直接跳过)
var goodsList = await _goodsRepository.Select
.Where(x => goodsIds.Contains(x.GoodsId) && x.Status == 0)
.ToListAsync();
if (goodsList.Count == 0)
{
return 0;
}
var goodsMap = new Dictionary<long, GoodsEntity>();
foreach (var goods in goodsList)
{
goodsMap[goods.GoodsId] = goods;
}
// 3. 批量取已存在的收藏记录
var existingList = await _favoriteRepository.Select
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
.ToListAsync();
var existingMap = new Dictionary<long, UserGoodsFavoriteEntity>();
foreach (var favorite in existingList)
{
existingMap[favorite.GoodsId] = favorite;
}
// 4. 逐条落库:不存在则新增,已存在且分组不同则修改分组
var insertedIds = new List<long>();
var changedGroupIds = new List<(long Id, int PreviousGroupId)>();
var documents = new List<UserFavoritesSearchDocument>();
try
{
foreach (var goods in goodsMap.Values)
{
if (existingMap.TryGetValue(goods.GoodsId, out var favorite) == false)
{
favorite = new UserGoodsFavoriteEntity
{
UserName = userName,
GoodsId = goods.GoodsId,
GroupId = request.GroupId,
CreatedAt = DateTime.Now,
};
try
{
await _favoriteRepository.InsertAsync(favorite);
insertedIds.Add(favorite.Id);
}
catch (Exception ex)
{
// 并发收藏同一商品时可能命中唯一键 uk_user_goods,回读已存在记录后按“修改分组”处理
favorite = await _favoriteRepository.Select
.Where(x => x.UserName == userName && x.GoodsId == goods.GoodsId)
.FirstAsync();
if (favorite == null)
{
throw new AppException($"收藏商品失败({goods.GoodsId}): {ex.Message}");
}
}
}
if (favorite.GroupId != request.GroupId)
{
changedGroupIds.Add((favorite.Id, favorite.GroupId));
await _favoriteRepository.Orm.Update<UserGoodsFavoriteEntity>()
.Set(x => x.GroupId, request.GroupId)
.Where(x => x.Id == favorite.Id)
.ExecuteAffrowsAsync();
favorite.GroupId = request.GroupId;
}
documents.Add(BuildDocument(favorite, goods, groupName));
}
}
catch
{
await CompensateWriteFailureAsync(insertedIds, changedGroupIds);
throw;
}
// 5. 批量同步写入索引
try
{
await _indexService.IndexDocumentsAsync(documents);
}
catch
{
await CompensateWriteFailureAsync(insertedIds, changedGroupIds);
throw;
}
return documents.Count;
}
/// <summary>
/// 批量取消收藏:先删除数据库记录,再删除索引文档;
/// 索引删除失败时回补数据库记录,避免“库里有收藏但搜索不到”。
/// </summary>
public async Task<int> RemoveFavoritesAsync(RemoveUserFavoritesRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
var userName = request.UserName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(userName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
var goodsIds = request.GoodsIds?.Where(x => x > 0).Distinct().ToList() ?? new List<long>();
if (goodsIds.Count == 0)
{
throw new AppException("请提供要取消收藏的商品编号", ResultCode.ValidationError);
}
var favorites = await _favoriteRepository.Select
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
.ToListAsync();
if (favorites.Count == 0)
{
return 0;
}
await _favoriteRepository.Orm.Delete<UserGoodsFavoriteEntity>()
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
.ExecuteAffrowsAsync();
try
{
await _indexService.DeleteDocumentsAsync(
favorites.Select(x => UserFavoritesSearchDocument.BuildId(x.UserName, x.GoodsId)));
}
catch (Exception ex)
{
// 索引删除失败:回补数据库记录(自增主键会重新生成,检索以“用户名+商品编号”为准,不受影响)
_logger.LogError(ex, "删除收藏索引失败,回补数据库收藏记录。UserName={UserName} Count={Count}", userName, favorites.Count);
await _favoriteRepository.InsertAsync(favorites);
throw;
}
return favorites.Count;
}
/// <summary>
/// 创建收藏分组:创建成功返回 true,同一用户同名分组已存在返回 false。
/// </summary>
public async Task<bool> CreateGroupAsync(CreateUserFavoriteGroupRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
var userName = request.UserName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(userName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
var groupName = request.GroupName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(groupName))
{
throw new AppException("分组名不能为空", ResultCode.ValidationError);
}
if (groupName.Length > 255)
{
throw new AppException("分组名长度不能超过255个字符", ResultCode.ValidationError);
}
var existing = await _groupRepository.Select
.Where(x => x.UserName == userName && x.GroupName == groupName)
.FirstAsync();
if (existing != null)
{
return false;
}
await _groupRepository.InsertAsync(new UserGoodsFavoriteGroupEntity
{
UserName = userName,
GroupName = groupName,
});
return true;
}
/// <summary>
/// 查询用户的收藏分组及分组下收藏数量,默认分组(分组ID=0)存在收藏时一并返回。
/// </summary>
public async Task<List<UserFavoriteGroupDto>> GetGroupsAsync(string userName)
{
userName = userName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(userName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
var groups = await _groupRepository.Select
.Where(x => x.UserName == userName)
.OrderBy(x => x.Id)
.ToListAsync();
var countList = await _favoriteRepository.Select
.Where(x => x.UserName == userName)
.GroupBy(x => x.GroupId)
.ToListAsync(x => new { GroupId = x.Key, Count = x.Count() });
var countMap = countList.ToDictionary(x => x.GroupId, x => (long)x.Count);
var result = new List<UserFavoriteGroupDto>();
if (countMap.TryGetValue(0, out var defaultCount) && defaultCount > 0)
{
result.Add(new UserFavoriteGroupDto
{
Id = 0,
UserName = userName,
GroupName = DefaultGroupName,
FavoriteCount = defaultCount,
});
}
result.AddRange(groups.Select(x => new UserFavoriteGroupDto
{
Id = x.Id,
UserName = x.UserName,
GroupName = x.GroupName,
FavoriteCount = countMap.TryGetValue((int)x.Id, out var count) ? count : 0,
}));
return result;
}
/// <summary>
/// 按 GoodsIds 顺序返回每个商品是否已被该用户收藏(商品编号无效的返回 false)。
/// 以数据库为准,不依赖索引,索引重建期间结果依然准确。
/// </summary>
public async Task<List<bool>> GetFavoriteStatusAsync(UserFavoriteStatusRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
var userName = request.UserName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(userName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
var goodsIds = request.GoodsIds ?? new List<long>();
if (goodsIds.Count == 0)
{
return new List<bool>();
}
var validIds = goodsIds.Where(x => x > 0).Distinct().ToList();
var favoritedIds = new HashSet<long>();
if (validIds.Count > 0)
{
var favorited = await _favoriteRepository.Select
.Where(x => x.UserName == userName && validIds.Contains(x.GoodsId))
.ToListAsync(x => x.GoodsId);
favoritedIds = favorited.ToHashSet();
}
// 结果与入参顺序一一对应,前台可直接按下标渲染
return goodsIds.Select(x => x > 0 && favoritedIds.Contains(x)).ToList();
}
/// <summary>
/// 从收藏索引检索当前用户的收藏商品。
/// </summary>
public async Task<(List<UserFavoriteItem> List, long Total)> SearchFavoritesAsync(UserFavoritesSearchRequest request)
{
if (request == null)
{
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
}
var userName = request.UserName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(userName))
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
request.UserName = userName;
var searchResult = await _indexService.SearchAsync(request, ParseSortBy(request.SortBy));
var list = searchResult.Documents.Select(ToItem).ToList();
return (list, searchResult.Total);
}
/// <summary>
/// 移除所有用户收藏索引并重新创建,然后从 user_goods_favorites 关联 goods 全量回填。
/// </summary>
public async Task<long> RebuildSearchIndexAsync()
{
// 1. 删除并重建索引(清空所有用户收藏索引)
await _indexService.ClearIndexAsync();
// 2. 从数据库读取全部收藏记录
var favorites = await _favoriteRepository.Select
.OrderBy(x => x.Id)
.ToListAsync();
if (favorites.Count == 0)
{
return 0;
}
// 3. 关联 goods 补全商品数据(标题/价格/销量/类目/平台/缩略图)
var goodsIds = favorites.Select(x => x.GoodsId).Distinct().ToList();
var goodsList = await _goodsRepository.Select
.Where(x => goodsIds.Contains(x.GoodsId))
.ToListAsync();
var goodsMap = new Dictionary<long, GoodsEntity>();
foreach (var goods in goodsList)
{
goodsMap[goods.GoodsId] = goods;
}
// 4. 关联分组表补全分组名
var groupIds = favorites.Where(x => x.GroupId > 0).Select(x => (long)x.GroupId).Distinct().ToList();
var groupMap = new Dictionary<int, string>();
if (groupIds.Count > 0)
{
var groups = await _groupRepository.Select
.Where(x => groupIds.Contains(x.Id))
.ToListAsync();
foreach (var group in groups)
{
groupMap[(int)group.Id] = group.GroupName;
}
}
var documents = new List<UserFavoritesSearchDocument>();
var missingGoodsCount = 0;
foreach (var favorite in favorites)
{
if (goodsMap.TryGetValue(favorite.GoodsId, out var goods) == false)
{
// 商品已被物理删除,跳过该收藏(不写入索引)
missingGoodsCount++;
continue;
}
var groupName = favorite.GroupId <= 0
? DefaultGroupName
: (groupMap.TryGetValue(favorite.GroupId, out var name) ? name : string.Empty);
documents.Add(BuildDocument(favorite, goods, groupName));
}
// 5. 分批写入索引
for (var offset = 0; offset < documents.Count; offset += RebuildBatchSize)
{
await _indexService.IndexDocumentsAsync(documents.Skip(offset).Take(RebuildBatchSize));
}
_logger.LogInformation(
"用户收藏索引重建完成:收藏 {FavoriteCount} 条,写入索引 {IndexedCount} 条,商品缺失跳过 {MissingCount} 条。",
favorites.Count, documents.Count, missingGoodsCount);
return documents.Count;
}
/// <summary>
/// 收藏落库成功但索引写入失败时的补偿:撤销本次新增的收藏记录、还原被修改的收藏分组。
/// </summary>
private async Task CompensateWriteFailureAsync(List<long> insertedIds, List<(long Id, int PreviousGroupId)> changedGroupIds)
{
try
{
if (insertedIds.Count > 0)
{
await _favoriteRepository.Orm.Delete<UserGoodsFavoriteEntity>()
.Where(x => insertedIds.Contains(x.Id))
.ExecuteAffrowsAsync();
}
foreach (var (id, previousGroupId) in changedGroupIds)
{
await _favoriteRepository.Orm.Update<UserGoodsFavoriteEntity>()
.Set(x => x.GroupId, previousGroupId)
.Where(x => x.Id == id)
.ExecuteAffrowsAsync();
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"回滚用户收藏记录失败。Inserted={InsertedCount} GroupChanged={GroupChangedCount}",
insertedIds.Count, changedGroupIds.Count);
}
}
/// <summary>
/// 取分组名:分组ID &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,
UserName = favorite.UserName,
GroupId = favorite.GroupId,
GroupName = groupName,
GoodsId = goods.GoodsId,
Platform = goods.Platform,
Title = goods.Title ?? string.Empty,
ThumbUrl = goods.ThumbUrl,
CatId1 = goods.CatId1,
CatId2 = goods.CatId2,
CatId3 = goods.CatId3,
CatId4 = goods.CatId4,
MinPrice = goods.MinPrice,
SoldQuantity = goods.SoldQuantity,
CreatedAt = favorite.CreatedAt,
};
}
private static UserFavoriteItem ToItem(UserFavoritesSearchDocument document)
{
return new UserFavoriteItem
{
Id = document.FavId,
UserName = document.UserName,
GroupId = document.GroupId,
GroupName = document.GroupName,
GoodsId = document.GoodsId,
Platform = document.Platform,
Title = document.Title,
ThumbUrl = document.ThumbUrl,
CatId1 = document.CatId1,
CatId2 = document.CatId2,
CatId3 = document.CatId3,
CatId4 = document.CatId4,
MinPrice = document.MinPrice,
SoldQuantity = document.SoldQuantity,
CreatedAt = document.CreatedAt,
};
}
/// <summary>
/// 解析排序方式,非法值一律按“收藏时间倒序”处理。
/// </summary>
private static UserFavoritesSortBy ParseSortBy(string? sortBy)
{
if (string.IsNullOrWhiteSpace(sortBy))
{
return UserFavoritesSortBy.CreatedTimeDesc;
}
return Enum.TryParse<UserFavoritesSortBy>(sortBy, ignoreCase: true, out var value)
? value
: UserFavoritesSortBy.CreatedTimeDesc;
}
}
}
-49
View File
@@ -1,49 +0,0 @@
using MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Store.Modules.CloudProduct.Services;
using System.ComponentModel;
namespace Store.Modules.CloudProduct
{
public class Startup : IStartup
{
public int Priority => IntPriority.Normal;
public void Initialize()
{
}
public void Register(IServiceRegistrar registrar)
{
Func<IServiceProvider, IFreeSql> KJDFsqlFactory = r =>
{
var config = r.GetRequiredService<IConfiguration>();
var connection = config.GetValue<string>("CloudProductConnection");
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.MySql, connection)
.UseAdoConnectionPool(true)
.Build();
return fsql;
};
registrar.RegisterSingleton<IFreeSql>("CloudProductConnection", (s, k) =>
{
return KJDFsqlFactory(s);
});
}
public async void Start(IServiceProvider serviceProvider)
{
var openSearchService = serviceProvider.GetRequiredService<OpenSearchService>();
await openSearchService.GetStatusAsync();
}
}
}
@@ -1,32 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
</ItemGroup>
<!--<ItemGroup>
--><!-- 本模块自己的配置文件:源码里叫 appsettings.json,构建后复制到宿主输出目录时改名为
appsettings.CloudProduct.json,避免和宿主自己的 appsettings.json 互相覆盖。
由 builder.AddStoreHub() 自动并入宿主配置树(在 appsettings*.json 之后、环境变量之前)。 --><!--
<None Update="appsettings.json" Link="appsettings.CloudProduct.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>-->
<ItemGroup>
<None Update="appsettings.CloudProduct.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
<PackageReference Include="Hangfire.Dashboard.Basic.Authentication" Version="8.0.0" />
<PackageReference Include="Hangfire.MySqlStorage" Version="2.0.3" />
<PackageReference Include="OpenSearch.Client" Version="2.1.0" />
</ItemGroup>
</Project>
@@ -1,25 +0,0 @@
{
"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",
"FavoritesIndexName": "user_goods_favorites_search"
},
"RawData": {
"Path": "E:\\GoodsRawData"
},
"Indexing": {
"Mode": "Background",
"Cron": "*/5 * * * *",
"BatchSize": 20000
},
"Delete": {
"SoftDelete": true
},
"Hangfire": {
"Dashboard" :{
"User": "admin",
"Pass": "1234560"
}
}
}
@@ -1,107 +0,0 @@
using Asp.Versioning;
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MySqlX.XDevAPI.Common;
using Store.Framework.Base;
using Store.Framework.Models;
using Store.ImageCache.Dtos;
using Store.ImageCache.Services;
using System;
using System.Collections.Generic;
namespace KJD_Web.Controllers
{
[ApiVersionNeutral]
[Route("api/ImageCache/[action]")]
public class ImageCacheController : AppControllerBase
{
private readonly ImageCacheService _imageCacheService;
private readonly ILogger<ImageCacheController> _logger;
public ImageCacheController(ImageCacheService imageCacheService, ILogger<ImageCacheController> logger)
{
if (imageCacheService == null)
{
throw new ArgumentNullException("imageCacheService");
}
_imageCacheService = imageCacheService;
_logger = logger;
}
/// <summary>
/// 批量按 MD5 查询图片缓存。
/// </summary>
[HttpPost]
public ApiResult<List<ImageCacheDto>> GetImageCache(ImageCacheBatchQueryRequest request)
{
List<ImageCacheDto> data = new List<ImageCacheDto>();
try
{
if (request != null && request.Md5s != null)
{
data = _imageCacheService.GetByMd5s(request.Md5s, request.Platform);
}
return ApiResult.Ok(data);
}
catch (ArgumentException ex)
{
return ApiResult.Fail(ex.Message);
}
}
/// <summary>
/// 按 MD5 和平台更新平台图片 URL。
/// </summary>
[HttpPost]
public ApiResult<List<string>> AddOrUpdate(ImageCacheUpdateUrlRequest[] requests)
{
if (requests == null || requests.Length == 0)
{
return ApiResult.Fail("参数不能为空");
}
var failedItems = new List<string>();
foreach (var item in requests)
{
if (item == null)
{
return ApiResult.Fail("图片参数不能包含空项", ResultCode.ValidationError);
}
try
{
if (!_imageCacheService.AddOrUpdate(item.Md5, item.Platform, item.PlatformUrl))
{
failedItems.Add(item.Md5);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "AddOrUpdate 失败, Md5={Md5}", item.Md5);
failedItems.Add(item.Md5);
}
}
return failedItems.Count == 0
? ApiResult.Ok()
: ApiResult.Fail("部分图片失败", failedItems);
}
public class ImageCacheBatchQueryRequest
{
public List<string> Md5s { get; set; }
public byte? Platform { get; set; }
}
public class ImageCacheUpdateUrlRequest
{
public string Md5 { get; set; }
public byte Platform { get; set; }
public string PlatformUrl { get; set; }
}
}
}
@@ -1,14 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Serialization;
namespace Store.ImageCache.Dtos
{
public class ImageCacheDto
{
public byte platform { get; set; }
public string md5 { get; set; }
public string platform_url { get; set; }
}
}
@@ -1,32 +0,0 @@
using FreeSql.DataAnnotations;
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Serialization;
namespace Store.ImageCache.Entities
{
[Table(Name = "images_0")]
public class ImageCacheEntity
{
[JsonIgnore]
[Column(Name = "id", IsIdentity = true, IsPrimary = true)]
public long id { get; set; }
[Column(Name = "platform")]
public byte platform { get; set; }
//[JsonProperty("md5")]
[Column(Name = "source_url_md5", StringLength = 32)]
public string source_url_md5 { get; set; }
[Column(Name = "platform_url", StringLength = 1000)]
public string platform_url { get; set; }
[JsonIgnore]
[Column(Name = "create_time")]
public DateTime create_time { get; set; }
[JsonIgnore]
[Column(Name = "update_time")]
public DateTime update_time { get; set; }
}
}
@@ -1,17 +0,0 @@
using Riok.Mapperly.Abstractions;
using Store.Framework.Models;
using Store.ImageCache.Dtos;
using Store.ImageCache.Entities;
using System;
using System.Collections.Generic;
using System.Text;
namespace Store.ImageCache.Mapper
{
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
public partial class ImageCacheMapper
{
[MapProperty(nameof(ImageCacheEntity.source_url_md5), nameof(ImageCacheDto.md5))]
public partial ImageCacheDto ToDto(ImageCacheEntity entity);
}
}
-47
View File
@@ -1,47 +0,0 @@
using MAX.Core.Interfaces;
using MAX.Framework.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MySql.Data.MySqlClient;
namespace Store.Modules.CloudProduct
{
public class Module : IModule
{
/// <summary>
/// 本模块自己的配置文件
/// </summary>
public const string ConfigurationFileName = "appsettings.ImageCache.json";
public int Priority => IntPriority.Normal;
/// <summary>
/// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
/// </summary>
public void AddModule(IHostApplicationBuilder host)
{
IServiceCollection services = host.Services;
IConfigurationManager configuration = host.Configuration;
if (!string.IsNullOrWhiteSpace(ConfigurationFileName))
{
string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(AppContext.BaseDirectory, ConfigurationFileName);
configuration.AddJsonFile(path, optional: false, reloadOnChange: true);
}
}
/// <summary>
/// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务
/// </summary>
public void UseModule(IApplicationBuilder app)
{
}
}
}
@@ -1,172 +0,0 @@
using MAX.Core.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Store.ImageCache.Dtos;
using Store.ImageCache.Entities;
using Store.ImageCache.Mapper;
using System;
using System.Collections.Generic;
using System.Text;
namespace Store.ImageCache.Services
{
public class ImageCacheService : ISingletonDependency
{
private const int Md5Length = 32;
private const int PlatformUrlMaxLength = 1000;
private readonly IFreeSql _freeSql;
private ImageCacheMapper mapper;
public ImageCacheService([FromKeyedServices("ImageCacheConnection")] IFreeSql freeSql)
{
mapper = new ImageCacheMapper();
if (freeSql == null)
{
throw new ArgumentNullException("freeSql");
}
_freeSql = freeSql;
}
/// <summary>
/// 按 MD5 查询图片缓存;platform 为空时返回该 MD5 在所有平台的缓存。
/// </summary>
public ImageCacheDto GetByMd5(string sourceUrlMd5, byte? platform = null)
{
var md5 = NormalizeMd5(sourceUrlMd5);
return GetByMd5sInternal(new List<string> { md5 }, platform).FirstOrDefault();
}
/// <summary>
/// 批量按 MD5 查询图片缓存。MD5 会按首字符分组,只查询对应的 images_0 到 images_f 分表。
/// </summary>
public List<ImageCacheDto> GetByMd5s(IEnumerable<string> sourceUrlMd5s, byte? platform = null)
{
return GetByMd5sInternal(NormalizeMd5s(sourceUrlMd5s), platform);
}
/// <summary>
/// 按 MD5 和平台直接修改平台图片 URL,未找到时返回 false。
/// </summary>
public bool AddOrUpdate(string sourceUrlMd5, byte platform, string platformUrl)
{
var md5 = NormalizeMd5(sourceUrlMd5);
ValidatePlatformUrl(platformUrl);
var tableName = GetTableName(md5[0]);
var existing = _freeSql.Select<ImageCacheEntity>()
.AsTable((type, oldName) => tableName)
.Where(a => a.source_url_md5 == sourceUrlMd5 && a.platform == platform)
.First();
var now = DateTime.Now;
if (existing != null)
{
var affected = _freeSql.Update<ImageCacheEntity>()
.AsTable(tableName)
.Set(a => a.platform_url, platformUrl)
.Set(a => a.update_time, now)
.Where(a => a.source_url_md5 == md5 && a.platform == platform)
.ExecuteAffrows();
return affected > 0;
}
else
{
ImageCacheEntity imageCache = new ImageCacheEntity();
imageCache.id = 0;
imageCache.source_url_md5 = md5;
imageCache.platform = platform;
imageCache.platform_url = platformUrl;
imageCache.create_time = now;
imageCache.update_time = now;
imageCache.id = _freeSql.Insert(imageCache)
.AsTable(tableName)
.ExecuteIdentity();
return imageCache.id > 0;
}
}
private List<ImageCacheDto> GetByMd5sInternal(List<string> md5s, byte? platform)
{
var result = new List<ImageCacheDto>();
if (md5s.Count == 0)
{
return result;
}
foreach (var shard in md5s.GroupBy(a => a[0]))
{
var tableName = GetTableName(shard.Key);
var md5sInTable = shard.ToList();
var query = _freeSql.Select<ImageCacheEntity>()
.AsTable((type, oldName) => tableName)
.Where(a => md5sInTable.Contains(a.source_url_md5));
if (platform.HasValue)
{
var platformValue = platform.Value;
query = query.Where(a => a.platform == platformValue);
}
result.AddRange(query.ToList().Select(mapper.ToDto));
}
return result;
}
private List<string> NormalizeMd5s(IEnumerable<string> sourceUrlMd5s)
{
if (sourceUrlMd5s == null)
{
throw new ArgumentNullException("sourceUrlMd5s");
}
var result = new List<string>();
foreach (var sourceUrlMd5 in sourceUrlMd5s)
{
var md5 = NormalizeMd5(sourceUrlMd5);
if (!result.Contains(md5))
{
result.Add(md5);
}
}
return result;
}
private string NormalizeMd5(string sourceUrlMd5)
{
if (string.IsNullOrWhiteSpace(sourceUrlMd5))
{
throw new ArgumentException("MD5 不能为空", "sourceUrlMd5");
}
var md5 = sourceUrlMd5.Trim().ToLowerInvariant();
if (md5.Length != Md5Length || md5.Any(a => !Uri.IsHexDigit(a)))
{
throw new ArgumentException("MD5 必须是 32 位十六进制字符串", "sourceUrlMd5");
}
return md5;
}
private string GetTableName(char shard)
{
return "images_" + shard;
}
private void ValidatePlatformUrl(string platformUrl)
{
if (string.IsNullOrWhiteSpace(platformUrl))
{
throw new ArgumentException("平台图片 URL 不能为空", "platformUrl");
}
if (platformUrl.Length > PlatformUrlMaxLength)
{
throw new ArgumentException("平台图片 URL 长度不能超过 1000", "platformUrl");
}
}
}
}
-45
View File
@@ -1,45 +0,0 @@
using MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.ComponentModel;
namespace Store.Modules.CloudProduct
{
public class Startup : IStartup
{
public int Priority => IntPriority.Normal;
public void Initialize()
{
}
public void Register(IServiceRegistrar registrar)
{
Func<IServiceProvider, IFreeSql> KJDFsqlFactory = r =>
{
var config = r.GetRequiredService<IConfiguration>();
var connection = config.GetValue<string>("ImageCache:ConnectionString");
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.MySql, connection)
.UseAdoConnectionPool(true)
.Build();
return fsql;
};
registrar.RegisterSingleton<IFreeSql>("ImageCacheConnection", (s, k) =>
{
return KJDFsqlFactory(s);
});
}
public void Start(IServiceProvider serviceProvider)
{
}
}
}
@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.ImageCache.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,5 +0,0 @@
{
"ImageCache": {
"ConnectionString": "Server=192.168.0.200;Port=3306;Database=ImageCache;Uid=root;Pwd=123456;Allow User Variables=true"
}
}