using MAX.Core.DependencyInjection; using MAX.Framework.Commons; using Microsoft.Extensions.Configuration; using MySqlX.XDevAPI; using OpenSearch.Client; using OpenSearch.Net; using Store.Modules.CloudProduct.Dtos; using Store.Modules.CloudProduct.Entities; namespace Store.Modules.CloudProduct.Services { public interface IOpenSearchServer: ISingletonDependency { Task AddOneRecordAsync(ProductSearchInfo goods); Task BulkIndexGoodsAsync(IEnumerable docs); Task ClearIndexAsync(); Task CountDocumentsAsync(); Task CreateIndexAsync(); Task DeleteDocumentsAsync(IEnumerable ids); Task DeleteIndexAsync(); Task GetStatusAsync(); Task RebuildIndexAsync(IEnumerable goodsList); Task<(long Total, List list)> SearchByTitleAsync(ProductSearchRequest request, GoodsSearchSortBy sortBy = GoodsSearchSortBy.Relevance); } internal class OpenSearchServer : IOpenSearchServer { private readonly string nodeUrl; private IOpenSearchClient _client = null!; private readonly string IndexName = ""; // 查询类操作(search/count/status/ping)统一超时 private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(5); // 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长,可挪到配置文件) private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30); public OpenSearchServer(IConfiguration configuration) { var config = configuration.GetSection("Search"); nodeUrl = config.GetValue("Url") ?? ""; IndexName = config.GetValue("IndexName") ?? ""; _client = CreateClient(); } private IOpenSearchClient CreateClient() { var pool = new SingleNodeConnectionPool(new Uri(nodeUrl)); var settings = new ConnectionSettings(pool) .DefaultIndex(IndexName) .DefaultMappingFor(m => m.IndexName(IndexName)) .RequestTimeout(QueryTimeout) .MaxRetryTimeout(QueryTimeout) .PrettyJson() .DisableDirectStreaming(); // 便于调试打印请求/响应报文 return new OpenSearchClient(settings); } /// /// ============ 1. 创建索引 ============ /// public async Task 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); } } /// /// 校验索引映射是否健康:title 字段必须使用 IK 分析器 /// private async Task IsIndexMappingHealthyAsync() { try { var getResponse = await _client.Indices.GetAsync(IndexName, d => d .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); if (!getResponse.IsValid || !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; } return textProperty.Analyzer == "ik_max_word" && textProperty.SearchAnalyzer == "ik_smart"; } catch { return false; } } private async Task TryCreateIndexAsync(bool useIk) { var indexAnalyzer = useIk ? "ik_max_word" : "standard"; var searchAnalyzer = useIk ? "ik_smart" : "standard"; return await _client.Indices.CreateAsync(IndexName, c => c .Settings(s => s .NumberOfShards(1) .NumberOfReplicas(0) ) .Map(m => m .Properties(p => p .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)) ); } /// /// ============ 2. 删除索引 ============ /// 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); } } /// /// ============ 3.1 批量写入商品搜索文档(存在则覆盖) ============ /// public async Task BulkIndexGoodsAsync(IEnumerable docs) { if (docs == null) throw new ArgumentNullException(nameof(docs)); var list = docs.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 _client.Indices.RefreshAsync(IndexName, d => d .RequestConfiguration(r => r.RequestTimeout(AdminTimeout))); } /// /// ============ 3.2 根据数据库商品重建搜索索引 ============ /// public async Task RebuildIndexAsync(IEnumerable goodsList) { if (goodsList == null) throw new ArgumentNullException(nameof(goodsList)); var docs = goodsList .Where(x => x != null && x.GoodsId > 0) .Select(x => new ProductSearchInfo { 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 (docs.Count == 0) return 0; await BulkIndexGoodsAsync(docs); return docs.Count; } /// /// ============ 3.3 按主键批量删除 OpenSearch 文档(软/硬删除共用) ============ /// public async Task DeleteDocumentsAsync(IEnumerable ids) { var list = ids?.Distinct().ToList() ?? new List(); 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(d => d.Index(IndexName).Id(id)); } b.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)); return b; }); if (!response.IsValid) { throw new AppException("删除 OpenSearch 文档失败: " + response.DebugInformation); } await _client.Indices.RefreshAsync(IndexName, d => d .RequestConfiguration(r => r.RequestTimeout(AdminTimeout))); } /// /// ============ 4. 手动新增一条 ============ /// public async Task AddOneRecordAsync(ProductSearchInfo goods) { if (goods == null) throw new ArgumentNullException(nameof(goods)); var response = await _client.IndexAsync(goods, i => i .Index(IndexName) .Id(goods.Id) .RequestConfiguration(r => r.RequestTimeout(QueryTimeout)) ); if (response.IsValid) { await _client.Indices.RefreshAsync(IndexName, d => d .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); } else { throw new AppException("新增失败: " + response.DebugInformation); } } /// /// ============ 5. 分词搜索(分页 + 类目/价格过滤 + 排序) ============ /// /// 搜索关键词 /// 一级类目,不传则不限制 /// 二级类目,不传则不限制 /// 三级类目,不传则不限制 /// 四级类目,不传则不限制 /// 最低价(含),不传则不限制 /// 最高价(含),不传则不限制 /// 排序方式 /// 页码,从 1 开始 /// 每页条数 public async Task<(long Total, List list)> SearchByTitleAsync(ProductSearchRequest request, GoodsSearchSortBy sortBy = GoodsSearchSortBy.Relevance) { string 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? minsold = request.MinSoldQuantity; int? maxsold = request.MaxSoldQuantity; int? platform = request.Platform; int pageIndex = request.PageIndex; int pageSize = request.PageSize; //if (string.IsNullOrWhiteSpace(keyword)) //{ // throw new AppException("关键词不能为空。"); //} if (pageIndex < 1) pageIndex = 1; if (pageSize < 1) pageSize = 20; if (pageSize > 2000) pageSize = 2000; // 避免深分页/单页过大拖垮性能,按需调整上限 var from = (pageIndex - 1) * pageSize; ISearchResponse searchResponse; try { searchResponse = await _client.SearchAsync(s => s .Index(IndexName) .Query(q => q .Bool(b => { if (string.IsNullOrWhiteSpace(keyword) == false) { b.Must(m => m.Match(mm => mm.Field(f => f.Title).Query(keyword))); } var filters = new List, 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 (minsold.HasValue || maxsold.HasValue) { filters.Add(f => f.Range(r => { var range = r.Field(x => x.SoldQuantity); if (minsold.HasValue) range = range.GreaterThanOrEquals(minsold.Value); if (maxsold.HasValue) range = range.LessThanOrEquals(maxsold.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); } //var ids = searchResponse.Documents.Select(d => d.Id).ToList(); return (searchResponse.Total, searchResponse.Documents.ToList()); } /// /// ============ 6. 统计文档数 ============ /// public async Task CountDocumentsAsync() { var countResponse = await _client.CountAsync(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); } } /// /// ============ 7. 查询 OpenSearch 状态 ============ /// public async Task GetStatusAsync() { 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; return status; } var exists = await _client.Indices.ExistsAsync(IndexName, d => d .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); status.IndexExists = exists.Exists; if (exists.Exists) { var count = await _client.CountAsync(c => c .Index(IndexName) .RequestConfiguration(r => r.RequestTimeout(QueryTimeout))); status.DocumentCount = count.IsValid ? count.Count : -1; if (!count.IsValid) status.Message = count.DebugInformation; } } catch (Exception ex) { status.Connected = false; status.Message = ex.Message; } return status; } /// /// 清空索引:删除后按统一映射重建,重建期间该索引不可查(由后台定时任务负责回填数据,接受短暂空窗) /// public async Task 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(); } } }