diff --git a/Directory.Packages.props b/Directory.Packages.props
index eae775c5f..0fe130716 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -360,7 +360,7 @@
-
+
diff --git a/aspnet-core/LINGYUN.MicroService.All.slnx b/aspnet-core/LINGYUN.MicroService.All.slnx
index 19b0fa78a..ea3848ab4 100644
--- a/aspnet-core/LINGYUN.MicroService.All.slnx
+++ b/aspnet-core/LINGYUN.MicroService.All.slnx
@@ -155,6 +155,7 @@
+
@@ -525,9 +526,12 @@
+
+
+
@@ -537,6 +541,8 @@
+
+
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/FodyWeavers.xml b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/FodyWeavers.xml
new file mode 100644
index 000000000..1715698cc
--- /dev/null
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/FodyWeavers.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/FodyWeavers.xsd b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/FodyWeavers.xsd
new file mode 100644
index 000000000..3f3946e28
--- /dev/null
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/FodyWeavers.xsd
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.
+
+
+
+
+ A comma-separated list of error codes that can be safely ignored in assembly verification.
+
+
+
+
+ 'false' to turn off automatic generation of the XML Schema file.
+
+
+
+
+
\ No newline at end of file
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN.Abp.Elasticsearch.EsqlQuery.csproj b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN.Abp.Elasticsearch.EsqlQuery.csproj
new file mode 100644
index 000000000..ccb6094d5
--- /dev/null
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN.Abp.Elasticsearch.EsqlQuery.csproj
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0
+ LINGYUN.Abp.Elasticsearch.EsqlQuery
+ LINGYUN.Abp.Elasticsearch.EsqlQuery
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN/Abp/Elasticsearch/EsqlQuery/AbpElasticsearchEsqlQueryModule.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN/Abp/Elasticsearch/EsqlQuery/AbpElasticsearchEsqlQueryModule.cs
new file mode 100644
index 000000000..4b2c9c7a8
--- /dev/null
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN/Abp/Elasticsearch/EsqlQuery/AbpElasticsearchEsqlQueryModule.cs
@@ -0,0 +1,8 @@
+using Volo.Abp.Modularity;
+
+namespace LINGYUN.Abp.Elasticsearch.EsqlQuery;
+
+[DependsOn(typeof(AbpElasticsearchModule))]
+public class AbpElasticsearchEsqlQueryModule : AbpModule
+{
+}
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN/Abp/Elasticsearch/EsqlQuery/EsqlExpressionQueryService.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN/Abp/Elasticsearch/EsqlQuery/EsqlExpressionQueryService.cs
new file mode 100644
index 000000000..231fd2379
--- /dev/null
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN/Abp/Elasticsearch/EsqlQuery/EsqlExpressionQueryService.cs
@@ -0,0 +1,83 @@
+using Elastic.Clients.Elasticsearch;
+using Elastic.Esql.Extensions;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Dynamic.Core;
+using System.Linq.Expressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Volo.Abp.DependencyInjection;
+
+namespace LINGYUN.Abp.Elasticsearch.EsqlQuery;
+
+///
+/// NOTE: The tests for methods like Contains in the collection have failed. Please do not use this interface for collection filtering.
+/// TODO: According to the official documentation, is it feasible? Is the support for nested types not available?
+/// See: https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/linq-to-esql#linq-esql-filtering
+/// var brands = new[] { "TechCorp", "StyleMax", "HomeBase" };
+/// client.Esql.Query(q => q.Where(p => brands.Contains(p.Brand)));
+/// → WHERE brand IN ("TechCorp", "StyleMax", "HomeBase")
+///
+
+[Dependency(ReplaceServices = true)]
+public class EsqlExpressionQueryService : ExpressionQueryService, ITransientDependency
+{
+ public EsqlExpressionQueryService(
+ IElasticsearchClientFactory clientFactory,
+ IExpressionQueryTranslator expressionQueryTranslator)
+ : base(clientFactory, expressionQueryTranslator)
+ {
+ }
+
+ public async override Task GetCountAsync(
+ string indexName,
+ Expression> expression,
+ CancellationToken cancellationToken = default) where TDocument : class
+ {
+ var client = ClientFactory.Create();
+
+ return await client.Esql.CreateQuery()
+ .From(indexName)
+ .Where(expression)
+ .AsEsqlQueryable()
+ .CountAsync(cancellationToken);
+ }
+
+ public async override Task> GetListAsync(
+ string indexName,
+ Expression> expression,
+ string? sorting = null,
+ int maxResultCount = 50,
+ int skipCount = 0,
+ Fields? sourceExcludes = null,
+ Fields? sourceIncludes = null,
+ object[]? beginMarker = null,
+ CancellationToken cancellationToken = default) where TDocument : class
+ {
+ var client = ClientFactory.Create();
+
+ var query = client.Esql.CreateQuery()
+ .From(indexName)
+ .Where(expression);
+ if (!sorting.IsNullOrWhiteSpace())
+ {
+ query = query.OrderBy(sorting);
+ }
+ if (sourceExcludes != null)
+ {
+ query = query.Drop(sourceExcludes.Select(f => f.Name!).ToArray());
+ }
+ if (sourceIncludes != null)
+ {
+ query = query.Keep(sourceIncludes.Select(f => f.Name!).ToArray());
+ }
+
+ // TODO: 需要构建范围过滤条件,加入到query中以实现分页查询
+ // See: https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/linq-to-esql#linq-esql-sorting
+ return await query
+ .Take(maxResultCount)
+ .AsEsqlQueryable()
+ .ToListAsync(cancellationToken);
+ }
+}
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/README.md b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/README.md
new file mode 100644
index 000000000..1af512617
--- /dev/null
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/README.md
@@ -0,0 +1,22 @@
+# LINGYUN.Abp.Elasticsearch.EsqlQuery
+
+[IExpressionQueryService](../LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IExpressionQueryService.cs) 的ES|QL查询集成.
+
+**注意**:
+* 使用 ES|QL 查询方案不能进行深度分页,数据条目超出10000时依旧存在ES分页限制.
+如需支持深度分页,请使用默认 [ExpressionQueryService](../LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs) `search_after` 方案
+
+* 不支持使用 由于ES|QL不支持 `Skip`, 暂不支持分页查询
+
+* 对于嵌套类型的集合方法测试未通过,请勿使用集合的过滤条件
+
+
+## 模块引用
+
+```csharp
+[DependsOn(typeof(AbpElasticsearchEsqlQueryModule))]
+public class YouProjectModule : AbpModule
+{
+ // other
+}
+```
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj
index 92d366e88..539a0f42f 100644
--- a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj
@@ -17,7 +17,7 @@
-
+
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/AbpElasticsearchModule.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/AbpElasticsearchModule.cs
index 64dd9b46f..6389a318d 100644
--- a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/AbpElasticsearchModule.cs
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/AbpElasticsearchModule.cs
@@ -1,14 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
+using Volo.Abp.Caching;
using Volo.Abp.Modularity;
-namespace LINGYUN.Abp.Elasticsearch
+namespace LINGYUN.Abp.Elasticsearch;
+
+[DependsOn(typeof(AbpCachingModule))]
+public class AbpElasticsearchModule : AbpModule
{
- public class AbpElasticsearchModule : AbpModule
+ public override void ConfigureServices(ServiceConfigurationContext context)
{
- public override void ConfigureServices(ServiceConfigurationContext context)
- {
- var configuration = context.Services.GetConfiguration();
- Configure(configuration.GetSection("Elasticsearch"));
- }
+ var configuration = context.Services.GetConfiguration();
+ Configure(configuration.GetSection("Elasticsearch"));
}
}
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ElasticsearchIndexMappingProvider.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ElasticsearchIndexMappingProvider.cs
new file mode 100644
index 000000000..38000d182
--- /dev/null
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ElasticsearchIndexMappingProvider.cs
@@ -0,0 +1,387 @@
+using Elastic.Clients.Elasticsearch;
+using Elastic.Clients.Elasticsearch.Mapping;
+using Elastic.Transport.Products.Elasticsearch;
+using Microsoft.Extensions.Caching.Memory;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Volo.Abp.DependencyInjection;
+
+namespace LINGYUN.Abp.Elasticsearch;
+
+public class ElasticsearchIndexMappingProvider : IIndexMappingProvider, ITransientDependency
+{
+ private readonly IMemoryCache _cache;
+ private readonly IElasticsearchClientFactory _clientFactory;
+ private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(10);
+
+ public ElasticsearchIndexMappingProvider(
+ IElasticsearchClientFactory clientFactory,
+ IMemoryCache cache)
+ {
+ _clientFactory = clientFactory;
+ _cache = cache;
+ }
+
+ public async Task GetMappingAsync(string indexName, CancellationToken cancellationToken = default)
+ {
+ var cacheKey = $"es_mapping_{indexName}";
+
+ var cacheItem = _cache.Get(cacheKey);
+ if (cacheItem == null)
+ {
+ var client = _clientFactory.Create();
+ var response = await client.Indices.GetMappingAsync(indexName, cancellationToken);
+
+ if (!response.IsSuccess())
+ {
+ var errorBuilder = new StringBuilder();
+ if (response.TryGetOriginalException(out var ex) && ex != null)
+ {
+ errorBuilder.AppendLine(ex.Message);
+ }
+ else if (response.TryGetElasticsearchServerError(out var error) && error != null)
+ {
+ errorBuilder.AppendLine(error.ToString());
+ }
+ else
+ {
+ errorBuilder.AppendLine(response.DebugInformation);
+ }
+ throw new Exception($"Failed to get mapping for index {indexName}: {errorBuilder.ToString()}");
+ }
+
+ if (!response.Mappings.TryGetValue(indexName, out var indexMappingRecord))
+ {
+ throw new Exception($"Index {indexName} not found in response");
+ }
+
+ cacheItem = ParseMapping(indexMappingRecord.Mappings, indexName);
+
+ _cache.Set(cacheKey, cacheItem, _cacheDuration);
+ }
+
+ return cacheItem;
+ }
+
+ private IndexMappingInfo ParseMapping(TypeMapping mappings, string indexName)
+ {
+ var mappingInfo = new IndexMappingInfo { IndexName = indexName };
+
+ if (mappings?.Properties != null)
+ {
+ ParseProperties(mappings.Properties, mappingInfo, string.Empty);
+ }
+
+ return mappingInfo;
+ }
+
+ private void ParseProperties(Properties? properties, IndexMappingInfo mappingInfo, string parentPath)
+ {
+ if (properties == null) return;
+
+ foreach (var kvp in properties)
+ {
+ var propertyName = kvp.Key.ToString();
+ var property = kvp.Value;
+ var fullPath = string.IsNullOrEmpty(parentPath)
+ ? propertyName
+ : $"{parentPath}.{propertyName}";
+
+ var fieldInfo = new FieldMappingInfo
+ {
+ Path = fullPath,
+ Name = propertyName,
+ Type = GetPropertyType(property)
+ };
+
+ switch (property)
+ {
+ // Keyword 类型
+ case KeywordProperty keyword:
+ fieldInfo.IsKeyword = true;
+ mappingInfo.KeywordFields.Add(fullPath);
+ break;
+
+ // Text 类型 - 包含多字段支持
+ case TextProperty text:
+ fieldInfo.IsText = true;
+ mappingInfo.TextFields.Add(fullPath);
+
+ // 处理 Text 的 Fields(多字段)
+ if (text.Fields != null && text.Fields.Count() > 0)
+ {
+ fieldInfo.Properties = new Dictionary();
+
+ foreach (var subFieldKvp in text.Fields)
+ {
+ var subFieldName = subFieldKvp.Key.ToString();
+ var subFieldProperty = subFieldKvp.Value;
+ var subFieldPath = $"{fullPath}.{subFieldName}";
+
+ var subFieldInfo = new FieldMappingInfo
+ {
+ Path = subFieldPath,
+ Name = subFieldName,
+ Type = GetPropertyType(subFieldProperty)
+ };
+
+ // 处理子字段的类型
+ if (subFieldProperty is KeywordProperty)
+ {
+ subFieldInfo.IsKeyword = true;
+ mappingInfo.KeywordFields.Add(subFieldPath);
+ }
+ else if (subFieldProperty is TextProperty)
+ {
+ subFieldInfo.IsText = true;
+ mappingInfo.TextFields.Add(subFieldPath);
+ }
+
+ fieldInfo.Properties[subFieldName] = subFieldInfo;
+ mappingInfo.Fields[subFieldPath] = subFieldInfo;
+ }
+ }
+ break;
+
+ // 日期类型
+ case DateProperty date:
+ fieldInfo.IsDate = true;
+ fieldInfo.Format = date.Format;
+ mappingInfo.DateFields.Add(fullPath);
+ break;
+
+ // 日期纳秒类型
+ case DateNanosProperty dateNanos:
+ fieldInfo.IsDate = true;
+ fieldInfo.Format = dateNanos.Format;
+ mappingInfo.DateFields.Add(fullPath);
+ break;
+
+ // 数值类型
+ case ByteNumberProperty:
+ case DoubleNumberProperty:
+ case FloatNumberProperty:
+ case HalfFloatNumberProperty:
+ case IntegerNumberProperty:
+ case LongNumberProperty:
+ case ScaledFloatNumberProperty:
+ case ShortNumberProperty:
+ case UnsignedLongNumberProperty:
+ fieldInfo.IsNumeric = true;
+ mappingInfo.NumericFields.Add(fullPath);
+ break;
+
+ // 布尔类型
+ case BooleanProperty:
+ fieldInfo.IsBoolean = true;
+ mappingInfo.BooleanFields.Add(fullPath);
+ break;
+
+ // Nested 类型
+ case NestedProperty nested:
+ fieldInfo.IsNested = true;
+ fieldInfo.IsObject = true;
+ mappingInfo.NestedFieldPaths.Add(fullPath);
+
+ var nestedInfo = new NestedMappingInfo
+ {
+ Path = fullPath,
+ Name = propertyName,
+ Properties = new Dictionary()
+ };
+
+ if (nested.Properties != null)
+ {
+ // 先递归解析内部字段
+ ParseProperties(nested.Properties, mappingInfo, fullPath);
+
+ // 收集 nested 内部的字段信息
+ foreach (var innerKvp in nested.Properties)
+ {
+ var innerName = innerKvp.Key.ToString();
+ var innerFullPath = $"{fullPath}.{innerName}";
+
+ if (mappingInfo.Fields.TryGetValue(innerFullPath, out var innerFieldInfo))
+ {
+ nestedInfo.Properties[innerName] = innerFieldInfo;
+ }
+ else
+ {
+ innerFieldInfo = new FieldMappingInfo
+ {
+ Path = innerFullPath,
+ Name = innerName,
+ Type = GetPropertyType(innerKvp.Value)
+ };
+ nestedInfo.Properties[innerName] = innerFieldInfo;
+ mappingInfo.Fields[innerFullPath] = innerFieldInfo;
+ }
+ }
+ }
+
+ mappingInfo.NestedFields[fullPath] = nestedInfo;
+ break;
+
+ // Object 类型
+ case ObjectProperty obj:
+ fieldInfo.IsObject = true;
+ fieldInfo.Properties = new Dictionary();
+
+ if (obj.Properties != null)
+ {
+ ParseProperties(obj.Properties, mappingInfo, fullPath);
+ }
+ break;
+
+ // 范围类型
+ case DateRangeProperty:
+ case DoubleRangeProperty:
+ case FloatRangeProperty:
+ case IntegerRangeProperty:
+ case LongRangeProperty:
+ case IpRangeProperty:
+ fieldInfo.IsRange = true;
+ break;
+
+ // 其他类型
+ case FlattenedProperty:
+ fieldInfo.Type = "flattened";
+ break;
+
+ case GeoPointProperty:
+ fieldInfo.Type = "geo_point";
+ break;
+
+ case GeoShapeProperty:
+ fieldInfo.Type = "geo_shape";
+ break;
+
+ case IpProperty:
+ fieldInfo.Type = "ip";
+ break;
+
+ case VersionProperty:
+ fieldInfo.Type = "version";
+ break;
+
+ case MatchOnlyTextProperty matchOnlyText:
+ fieldInfo.IsText = true;
+ fieldInfo.Type = "match_only_text";
+ mappingInfo.TextFields.Add(fullPath);
+
+ // MatchOnlyText 也可能有 Fields
+ if (matchOnlyText.Fields != null && matchOnlyText.Fields.Count() > 0)
+ {
+ fieldInfo.Properties = new Dictionary();
+ foreach (var subFieldKvp in matchOnlyText.Fields)
+ {
+ var subFieldName = subFieldKvp.Key.ToString();
+ var subFieldPath = $"{fullPath}.{subFieldName}";
+ var subFieldInfo = new FieldMappingInfo
+ {
+ Path = subFieldPath,
+ Name = subFieldName,
+ Type = GetPropertyType(subFieldKvp.Value)
+ };
+ if (subFieldKvp.Value is KeywordProperty)
+ {
+ subFieldInfo.IsKeyword = true;
+ mappingInfo.KeywordFields.Add(subFieldPath);
+ }
+ fieldInfo.Properties[subFieldName] = subFieldInfo;
+ mappingInfo.Fields[subFieldPath] = subFieldInfo;
+ }
+ }
+ break;
+
+ case WildcardProperty:
+ fieldInfo.IsWildcard = true;
+ fieldInfo.Type = "wildcard";
+ mappingInfo.WildcardFields.Add(fullPath);
+ break;
+
+ case CompletionProperty:
+ fieldInfo.Type = "completion";
+ break;
+
+ case JoinProperty:
+ fieldInfo.Type = "join";
+ break;
+
+ case PercolatorProperty:
+ fieldInfo.Type = "percolator";
+ break;
+
+ case RankFeatureProperty:
+ fieldInfo.Type = "rank_feature";
+ break;
+
+ case RankFeaturesProperty:
+ fieldInfo.Type = "rank_features";
+ break;
+
+ case DenseVectorProperty:
+ fieldInfo.Type = "dense_vector";
+ break;
+
+ case SparseVectorProperty:
+ fieldInfo.Type = "sparse_vector";
+ break;
+
+ default:
+ fieldInfo.Type = property.GetType().Name.Replace("Property", "").ToLowerInvariant();
+ break;
+ }
+
+ mappingInfo.Fields[fullPath] = fieldInfo;
+ }
+ }
+
+ private string GetPropertyType(IProperty property)
+ {
+ return property switch
+ {
+ KeywordProperty => "keyword",
+ TextProperty => "text",
+ DateProperty => "date",
+ DateNanosProperty => "date_nanos",
+ ByteNumberProperty => "byte",
+ DoubleNumberProperty => "double",
+ FloatNumberProperty => "float",
+ HalfFloatNumberProperty => "half_float",
+ IntegerNumberProperty => "integer",
+ LongNumberProperty => "long",
+ ScaledFloatNumberProperty => "scaled_float",
+ ShortNumberProperty => "short",
+ UnsignedLongNumberProperty => "unsigned_long",
+ BooleanProperty => "boolean",
+ NestedProperty => "nested",
+ ObjectProperty => "object",
+ FlattenedProperty => "flattened",
+ GeoPointProperty => "geo_point",
+ GeoShapeProperty => "geo_shape",
+ IpProperty => "ip",
+ VersionProperty => "version",
+ MatchOnlyTextProperty => "match_only_text",
+ WildcardProperty => "wildcard",
+ CompletionProperty => "completion",
+ JoinProperty => "join",
+ PercolatorProperty => "percolator",
+ RankFeatureProperty => "rank_feature",
+ RankFeaturesProperty => "rank_features",
+ DenseVectorProperty => "dense_vector",
+ SparseVectorProperty => "sparse_vector",
+ DateRangeProperty => "date_range",
+ DoubleRangeProperty => "double_range",
+ FloatRangeProperty => "float_range",
+ IntegerRangeProperty => "integer_range",
+ LongRangeProperty => "long_range",
+ IpRangeProperty => "ip_range",
+ _ => property.GetType().Name.Replace("Property", "").ToLowerInvariant()
+ };
+ }
+}
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs
new file mode 100644
index 000000000..e51b09fa8
--- /dev/null
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs
@@ -0,0 +1,273 @@
+using Elastic.Clients.Elasticsearch;
+using Elastic.Clients.Elasticsearch.QueryDsl;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Volo.Abp.DependencyInjection;
+
+namespace LINGYUN.Abp.Elasticsearch;
+
+public class ExpressionQueryService : IExpressionQueryService, ITransientDependency
+{
+ protected IElasticsearchClientFactory ClientFactory { get; }
+ protected IExpressionQueryTranslator ExpressionQueryTranslator { get; }
+
+ public ExpressionQueryService(
+ IElasticsearchClientFactory clientFactory,
+ IExpressionQueryTranslator expressionQueryTranslator)
+ {
+ ClientFactory = clientFactory;
+ ExpressionQueryTranslator = expressionQueryTranslator;
+ }
+
+ public async virtual Task GetCountAsync(
+ string indexName,
+ Expression> expression,
+ CancellationToken cancellationToken = default) where TDocument : class
+ {
+ var client = ClientFactory.Create();
+ var query = await ExpressionQueryTranslator.TranslateAsync(indexName, expression);
+
+ var response = await client.CountAsync(dsl =>
+ dsl.Indices(indexName).Query(query),
+ cancellationToken);
+
+ return response.Count;
+ }
+
+ public async virtual Task> GetListAsync(
+ string indexName,
+ Expression> expression,
+ string? sorting = null,
+ int maxResultCount = 50,
+ int skipCount = 0,
+ Fields? sourceExcludes = null,
+ Fields? sourceIncludes = null,
+ object[]? beginMarker = null,
+ CancellationToken cancellationToken = default) where TDocument : class
+ {
+ var client = ClientFactory.Create();
+ var query = await ExpressionQueryTranslator.TranslateAsync(indexName, expression);
+
+ SortOptions[]? sorts = null;
+ if (!sorting.IsNullOrWhiteSpace())
+ {
+ var sortOrder = !sorting.IsNullOrWhiteSpace() && sorting.EndsWith("asc", StringComparison.InvariantCultureIgnoreCase)
+ ? SortOrder.Asc : SortOrder.Desc;
+
+ sorts = new SortOptions[1]
+ {
+ new SortOptions
+ {
+ Field = new FieldSort(new Field(sorting))
+ {
+ Order = sortOrder,
+ },
+ }
+ };
+ }
+
+ // 数量超过10000且存在排序时才可以使用SearchAfter特性
+ return skipCount >= 10000 && sorts != null
+ ? await SearchAfter(
+ client,
+ indexName,
+ query,
+ sorts,
+ maxResultCount,
+ skipCount,
+ sourceExcludes,
+ sourceIncludes,
+ beginMarker,
+ cancellationToken)
+ : await SearchFromSize(
+ client,
+ indexName,
+ query,
+ sorts,
+ maxResultCount,
+ skipCount,
+ sourceExcludes,
+ sourceIncludes,
+ cancellationToken);
+ }
+
+ private async Task> SearchFromSize(
+ ElasticsearchClient client,
+ string indexName,
+ Query query,
+ SortOptions[]? sorts = null,
+ int maxResultCount = 50,
+ int skipCount = 0,
+ Fields? sourceExcludes = null,
+ Fields? sourceIncludes = null,
+ CancellationToken cancellationToken = default)
+ {
+ var searchResponse = await client.SearchAsync(dsl =>
+ {
+ dsl.Indices(indexName)
+ .Query(query)
+ .From(skipCount)
+ .Size(maxResultCount);
+ if (sorts != null)
+ {
+ dsl.Sort(sorts);
+ }
+ if (sourceExcludes != null)
+ {
+ dsl.SourceExcludes(sourceExcludes);
+ }
+ if (sourceIncludes != null)
+ {
+ dsl.SourceIncludes(sourceIncludes);
+ }
+ }, cancellationToken);
+
+ if (!searchResponse.IsSuccess())
+ {
+ return [];
+ }
+
+ return searchResponse.Documents.ToList();
+ }
+
+ private async Task> SearchAfter(
+ ElasticsearchClient client,
+ string indexName,
+ Query query,
+ SortOptions[] sorts,
+ int maxResultCount,
+ int skipCount,
+ Fields? sourceExcludes = null,
+ Fields? sourceIncludes = null,
+ object[]? beginMarker = null,
+ CancellationToken cancellationToken = default)
+ {
+ List? searchAfter = null;
+ if (beginMarker != null)
+ {
+ searchAfter = beginMarker.Select(FieldValue.FromValue).ToList();
+ }
+ else
+ {
+ searchAfter = await GetSearchAfterValue(
+ client,
+ indexName,
+ query,
+ sorts,
+ skipCount,
+ cancellationToken);
+ }
+
+ if (searchAfter == null || !searchAfter.Any())
+ {
+ return [];
+ }
+
+ var searchResponse = await client.SearchAsync(dsl =>
+ {
+ dsl.Indices(indexName)
+ .Query(query)
+ .Sort(sorts)
+ .Size(maxResultCount)
+ .SearchAfter(searchAfter);
+ if (sourceExcludes != null)
+ {
+ dsl.SourceExcludes(sourceExcludes);
+ }
+ if (sourceIncludes != null)
+ {
+ dsl.SourceIncludes(sourceIncludes);
+ }
+ }, cancellationToken);
+
+ if (!searchResponse.IsSuccess())
+ {
+ return [];
+ }
+
+ return searchResponse.Documents.ToList();
+ }
+
+ private async Task?> GetSearchAfterValue(
+ ElasticsearchClient client,
+ string indexName,
+ Query query,
+ SortOptions[] sorts,
+ int skipCount,
+ CancellationToken cancellationToken = default)
+ {
+ // 10000以内直接取最后一条数据
+ if (skipCount < 10000)
+ {
+ var response = await client.SearchAsync(
+ dsl => dsl.Indices(indexName)
+ .Query(query)
+ .Sort(sorts)
+ .From(skipCount)
+ .Size(1),
+ cancellationToken);
+
+ if (!response.IsSuccess() || response.Hits == null || !response.Hits.Any())
+ {
+ return null;
+ }
+
+ var hit = response.Hits.FirstOrDefault();
+ return hit?.Sort?.ToList();
+ }
+
+ // 获取第9999条数据Hits作为searchAfter
+ var firstResponse = await client.SearchAsync(
+ dsl => dsl.Indices(indexName)
+ .Query(query)
+ .Sort(sorts)
+ .SourceIncludes([])
+ .From(9999)
+ .Size(1),
+ cancellationToken);
+
+ if (!firstResponse.IsSuccess() || firstResponse.Hits == null || !firstResponse.Hits.Any())
+ {
+ return null;
+ }
+
+ var firstHit = firstResponse.Hits.FirstOrDefault();
+ if (firstHit?.Sort == null || !firstHit.Sort.Any())
+ {
+ return null;
+ }
+
+ var remaining = skipCount - 10000;
+ // 获取skipCount最近一条数据作为searchAfter
+ var secondResponse = await client.SearchAsync(
+ dsl => dsl.Indices(indexName)
+ .Query(query)
+ .Sort(sorts)
+ .SourceIncludes([])
+ .SearchAfter(firstHit.Sort.ToList())
+ .Size(remaining),
+ cancellationToken);
+
+ if (!secondResponse.IsSuccess() || secondResponse.Hits == null || !secondResponse.Hits.Any())
+ {
+ return null;
+ }
+
+ if (secondResponse.Hits.Count < remaining)
+ {
+ return null;
+ }
+
+ var lastHit = secondResponse.Hits.LastOrDefault();
+ if (lastHit?.Sort == null || !lastHit.Sort.Any())
+ {
+ return null;
+ }
+
+ return lastHit.Sort.ToList();
+ }
+}
diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Collection.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Collection.cs
new file mode 100644
index 000000000..ee5272a5b
--- /dev/null
+++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Collection.cs
@@ -0,0 +1,98 @@
+using Elastic.Clients.Elasticsearch.QueryDsl;
+using System;
+using System.Collections;
+using System.Linq;
+using System.Linq.Expressions;
+
+namespace LINGYUN.Abp.Elasticsearch;
+
+public partial class ExpressionQueryTranslator
+{
+ ///
+ /// 翻译集合 Contains
+ ///
+ private Query TranslateCollectionContains(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo)
+ {
+ // list.Contains(value) 或 hashSet.Contains(value)
+ // 有两种情况:
+ // 1. 外部集合.Contains(字段) - 例如:new[]{"a","b"}.Contains(x.xxx)
+ // 2. 字段.Contains(值) - 例如:x.Y.Contains("B")
+
+ Expression collectionExpr = node.Object!;
+ Expression valueExpr = node.Arguments[0];
+
+ // 判断哪个是字段,哪个是值
+ var collectionIsField = IsFieldLike(collectionExpr);
+ var valueIsField = IsFieldLike(valueExpr);
+
+ if (collectionIsField && !valueIsField)
+ {
+ // 情况 2:字段.Contains(值)
+ // 这通常用于集合字段,如 x.Tags.Contains("B")
+ var field = ResolveField(collectionExpr, prefix, mappingInfo);
+ var value = Evaluate(valueExpr);
+
+ if (value == null)
+ {
+ return new MatchNoneQuery();
+ }
+
+ // 对于集合字段,使用 Terms 查询或 Term 查询
+ // 如果字段是数组或集合类型,单个值的 Contains 实际上就是 Term 查询
+ return BuildEquality(field, value);
+ }
+ else if (!collectionIsField && valueIsField)
+ {
+ // 情况 1:外部集合.Contains(字段)
+ var field = ResolveField(valueExpr, prefix, mappingInfo);
+
+ // 尝试获取集合的值
+ var collectionValue = Evaluate(collectionExpr);
+
+ if (collectionValue is IEnumerable enumerable && collectionValue is not string)
+ {
+ var values = enumerable.Cast