Commit 13320ac6b0d8b3ca7a554e3d9923b019faaf7fb9

Authored by tangwang
1 parent e7ad2b4a

分面接口修改:

{
  "facets": [
    {
      "field": "category1_name",
      "size": 15,
      "type": "terms"
    },
    "specifications.color",
    "specifications.size"
  ]
}

{
  "facets": [
    {"field": "category1_name", "size": 15, "type": "terms"},
    {"field": "specifications.color", "size": 10, "type": "terms"},
    {"field": "specifications.size", "size": 10, "type": "terms"}
  ]
}

之前是上面的接口形式,主要是考虑 属性的分面, 因为 款式都是有限的 不需要设定 "size": 10, "type": "terms" 这些参数。

但是从接口设计层面,最好按下面这样,这样的话 specifications.color 和 category1_name 的组装格式 完全一样。前端不需要感知 属性分面 和 类别等其他字段分面的差异。
activate.sh 0 → 100644
@@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
  1 +#!/bin/bash
  2 +source /home/tw/miniconda3/etc/profile.d/conda.sh
  3 +conda activate searchengine
  4 +
  5 +# 如果需要加载 .env 中的环境变量
  6 +if [ -f .env ]; then
  7 + set -a # 自动导出所有变量
  8 + source <(grep -v '^#' .env | grep -v '^$' | sed 's/#.*$//' | sed 's/\r$//')
  9 + set +a # 关闭自动导出
  10 +fi
  11 +
  12 +echo "Environment activated: searchengine"
@@ -110,31 +110,34 @@ class SearchRequest(BaseModel): @@ -110,31 +110,34 @@ class SearchRequest(BaseModel):
110 ) 110 )
111 111
112 # 排序 112 # 排序
113 - sort_by: Optional[str] = Field(None, description="排序字段名(如 'min_price', 'max_price', 'title')")  
114 - sort_order: Optional[str] = Field("desc", description="排序方向: 'asc'(升序)或 'desc'(降序)") 113 + sort_by: Optional[str] = Field(None, description="排序字段名。支持:'price'(价格,自动根据sort_order选择min_price或max_price)、'sales'(销量)、'create_time'(创建时间)、'update_time'(更新时间)")
  114 + sort_order: Optional[str] = Field("desc", description="排序方向: 'asc'(升序)或 'desc'(降序)。注意:price+asc=价格从低到高,price+desc=价格从高到低")
115 115
116 - # 分面搜索 - 简化接口  
117 - facets: Optional[List[Union[str, FacetConfig]]] = Field( 116 + # 分面搜索
  117 + facets: Optional[List[FacetConfig]] = Field(
118 None, 118 None,
119 - description="分面配置。可以是字段名列表(使用默认配置)或详细的分面配置对象。支持 specifications 分面:\"specifications\"(所有name)或 \"specifications.color\"(指定name)", 119 + description="分面配置对象列表。支持 specifications 分面:field=\"specifications\"(所有规格名称)或 field=\"specifications.color\"(指定规格名称)",
120 json_schema_extra={ 120 json_schema_extra={
121 "examples": [ 121 "examples": [
122 - # 简单模式:只指定字段名,使用默认配置  
123 - ["category1_name", "category2_name", "specifications"],  
124 - # 指定specifications的某个name  
125 - ["specifications.颜色", "specifications.尺寸"],  
126 - # 高级模式:详细配置  
127 [ 122 [
128 - {"field": "category1_name", "size": 15}, 123 + {"field": "category1_name", "size": 15, "type": "terms"},
  124 + {"field": "category2_name", "size": 10, "type": "terms"},
  125 + {"field": "specifications.color", "size": 20, "type": "terms"},
  126 + {"field": "specifications.size", "size": 15, "type": "terms"}
  127 + ],
  128 + [
  129 + {"field": "category1_name", "size": 15, "type": "terms"},
129 { 130 {
130 "field": "min_price", 131 "field": "min_price",
131 "type": "range", 132 "type": "range",
132 "ranges": [ 133 "ranges": [
133 {"key": "0-50", "to": 50}, 134 {"key": "0-50", "to": 50},
134 - {"key": "50-100", "from": 50, "to": 100} 135 + {"key": "50-100", "from": 50, "to": 100},
  136 + {"key": "100-200", "from": 100, "to": 200},
  137 + {"key": "200+", "from": 200}
135 ] 138 ]
136 }, 139 },
137 - "specifications" # 所有specifications name的分面 140 + {"field": "specifications", "size": 10, "type": "terms"}
138 ] 141 ]
139 ] 142 ]
140 } 143 }
docs/搜索API对接指南.md
@@ -50,7 +50,7 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -50,7 +50,7 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
50 "gte": "2020-01-01T00:00:00Z" 50 "gte": "2020-01-01T00:00:00Z"
51 } 51 }
52 }, 52 },
53 - "sort_by": "min_price", 53 + "sort_by": "price",
54 "sort_order": "asc" 54 "sort_order": "asc"
55 }' 55 }'
56 ``` 56 ```
@@ -63,7 +63,11 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -63,7 +63,11 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
63 -H "X-Tenant-ID: 162" \ 63 -H "X-Tenant-ID: 162" \
64 -d '{ 64 -d '{
65 "query": "芭比娃娃", 65 "query": "芭比娃娃",
66 - "facets": ["category1_name", "specifications.color", "specifications.size", "specifications.material"], 66 + "facets": [
  67 + {"field": "category1_name", "size": 10, "type": "terms"},
  68 + {"field": "specifications.color", "size": 10, "type": "terms"},
  69 + {"field": "specifications.size", "size": 10, "type": "terms"}
  70 + ],
67 "min_score": 0.2 71 "min_score": 0.2
68 }' 72 }'
69 ``` 73 ```
@@ -124,8 +128,8 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -124,8 +128,8 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
124 | `filters` | object | N | null | 精确匹配过滤器(见下文) | 128 | `filters` | object | N | null | 精确匹配过滤器(见下文) |
125 | `range_filters` | object | N | null | 数值范围过滤器(见下文) | 129 | `range_filters` | object | N | null | 数值范围过滤器(见下文) |
126 | `facets` | array | N | null | 分面配置(见下文) | 130 | `facets` | array | N | null | 分面配置(见下文) |
127 -| `sort_by` | string | N | null | 排序字段名(如 `min_price`, `max_price`) |  
128 -| `sort_order` | string | N | "desc" | 排序方向:`asc`(升序)或 `desc`(降序) | 131 +| `sort_by` | string | N | null | 排序字段名。支持:`price`(价格)、`sales`(销量)、`create_time`(创建时间)、`update_time`(更新时间)。默认按相关性排序 |
  132 +| `sort_order` | string | N | "desc" | 排序方向:`asc`(升序)或 `desc`(降序)。注意:`price`+`asc`=价格从低到高,`price`+`desc`=价格从高到低(后端自动映射为min_price或max_price) |
129 | `min_score` | float | N | null | 最小相关性分数阈值 | 133 | `min_score` | float | N | null | 最小相关性分数阈值 |
130 | `sku_filter_dimension` | array[string] | N | null | 子SKU筛选维度列表(店铺配置)。指定后,每个SPU下的SKU将按这些维度的组合进行分组,每个组合只返回第一个SKU。支持的值:`option1`、`option2`、`option3` 或选项名称(如 `color`、`size`)。详见下文说明 | 134 | `sku_filter_dimension` | array[string] | N | null | 子SKU筛选维度列表(店铺配置)。指定后,每个SPU下的SKU将按这些维度的组合进行分组,每个组合只返回第一个SKU。支持的值:`option1`、`option2`、`option3` 或选项名称(如 `color`、`size`)。详见下文说明 |
131 | `debug` | boolean | N | false | 是否返回调试信息 | 135 | `debug` | boolean | N | false | 是否返回调试信息 |
@@ -263,14 +267,8 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -263,14 +267,8 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
263 267
264 用于生成分面统计(分组聚合),常用于构建筛选器UI。 268 用于生成分面统计(分组聚合),常用于构建筛选器UI。
265 269
266 -**简单模式**(字符串数组):  
267 -```json  
268 -{  
269 - "facets": ["category1_name", "category2_name", "category3_name", "specifications"]  
270 -}  
271 -``` 270 +**配置格式**(对象数组):
272 271
273 -**高级模式**(配置对象数组):  
274 ```json 272 ```json
275 { 273 {
276 "facets": [ 274 "facets": [
@@ -280,6 +278,16 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -280,6 +278,16 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
280 "type": "terms" 278 "type": "terms"
281 }, 279 },
282 { 280 {
  281 + "field": "category2_name",
  282 + "size": 10,
  283 + "type": "terms"
  284 + },
  285 + {
  286 + "field": "specifications.color",
  287 + "size": 20,
  288 + "type": "terms"
  289 + },
  290 + {
283 "field": "min_price", 291 "field": "min_price",
284 "type": "range", 292 "type": "range",
285 "ranges": [ 293 "ranges": [
@@ -288,8 +296,7 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -288,8 +296,7 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
288 {"key": "100-200", "from": 100, "to": 200}, 296 {"key": "100-200", "from": 100, "to": 200},
289 {"key": "200+", "from": 200} 297 {"key": "200+", "from": 200}
290 ] 298 ]
291 - },  
292 - "specifications" // 规格分面(特殊处理:嵌套聚合,按name分组,然后按value聚合) 299 + }
293 ] 300 ]
294 } 301 }
295 ``` 302 ```
@@ -298,18 +305,35 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -298,18 +305,35 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
298 305
299 `specifications` 是嵌套字段,支持两种分面模式: 306 `specifications` 是嵌套字段,支持两种分面模式:
300 307
301 -**模式1:所有规格名称的分面** (`"specifications"`): 308 +**模式1:所有规格名称的分面**:
302 ```json 309 ```json
303 { 310 {
304 - "facets": ["specifications"] 311 + "facets": [
  312 + {
  313 + "field": "specifications",
  314 + "size": 10,
  315 + "type": "terms"
  316 + }
  317 + ]
305 } 318 }
306 ``` 319 ```
307 返回所有规格名称(name)及其对应的值(value)列表。每个 name 会生成一个独立的分面结果。 320 返回所有规格名称(name)及其对应的值(value)列表。每个 name 会生成一个独立的分面结果。
308 321
309 -**模式2:指定规格名称的分面** (`"specifications.color"`): 322 +**模式2:指定规格名称的分面**:
310 ```json 323 ```json
311 { 324 {
312 - "facets": ["specifications.color", "specifications.size", "specifications.material"] 325 + "facets": [
  326 + {
  327 + "field": "specifications.color",
  328 + "size": 20,
  329 + "type": "terms"
  330 + },
  331 + {
  332 + "field": "specifications.size",
  333 + "size": 15,
  334 + "type": "terms"
  335 + }
  336 + ]
313 } 337 }
314 ``` 338 ```
315 只返回指定规格名称的值列表。格式:`specifications.{name}`,其中 `{name}` 是规格名称(如"color"、"size"、"material")。 339 只返回指定规格名称的值列表。格式:`specifications.{name}`,其中 `{name}` 是规格名称(如"color"、"size"、"material")。
@@ -564,6 +588,7 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -564,6 +588,7 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
564 | `sku_weights` | array[integer] | 所有SKU重量列表 | 588 | `sku_weights` | array[integer] | 所有SKU重量列表 |
565 | `sku_weight_units` | array[string] | 所有SKU重量单位列表 | 589 | `sku_weight_units` | array[string] | 所有SKU重量单位列表 |
566 | `total_inventory` | integer | 总库存 | 590 | `total_inventory` | integer | 总库存 |
  591 +| `sales` | integer | 销量(展示销量) |
567 | `option1_name` | string | 选项1名称(如"color") | 592 | `option1_name` | string | 选项1名称(如"color") |
568 | `option2_name` | string | 选项2名称(如"size") | 593 | `option2_name` | string | 选项2名称(如"size") |
569 | `option3_name` | string | 选项3名称 | 594 | `option3_name` | string | 选项3名称 |
@@ -605,11 +630,45 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -605,11 +630,45 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
605 "query": "玩具", 630 "query": "玩具",
606 "size": 20, 631 "size": 20,
607 "from": 0, 632 "from": 0,
608 - "sort_by": "min_price", 633 + "sort_by": "price",
609 "sort_order": "asc" 634 "sort_order": "asc"
610 } 635 }
611 ``` 636 ```
612 637
  638 +**需求**: 搜索"玩具",按价格从高到低排序
  639 +
  640 +```json
  641 +{
  642 + "query": "玩具",
  643 + "size": 20,
  644 + "from": 0,
  645 + "sort_by": "price",
  646 + "sort_order": "desc"
  647 +}
  648 +```
  649 +
  650 +**需求**: 搜索"玩具",按销量从高到低排序
  651 +
  652 +```json
  653 +{
  654 + "query": "玩具",
  655 + "size": 20,
  656 + "from": 0,
  657 + "sort_by": "sales",
  658 + "sort_order": "desc"
  659 +}
  660 +```
  661 +
  662 +**需求**: 搜索"玩具",按默认(相关性)排序
  663 +
  664 +```json
  665 +{
  666 + "query": "玩具",
  667 + "size": 20,
  668 + "from": 0
  669 +}
  670 +```
  671 +
613 ### 场景2:SKU筛选(按维度过滤) 672 ### 场景2:SKU筛选(按维度过滤)
614 673
615 **需求**: 搜索"芭比娃娃",每个SPU下按颜色筛选,每种颜色只显示一个SKU 674 **需求**: 搜索"芭比娃娃",每个SPU下按颜色筛选,每种颜色只显示一个SKU
@@ -650,7 +709,7 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -650,7 +709,7 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
650 709
651 ### 场景4:带分面的商品搜索 710 ### 场景4:带分面的商品搜索
652 711
653 -**需求**: 搜索"玩具",获取类目和品牌的分面统计,用于构建筛选器 712 +**需求**: 搜索"玩具",获取类目和规格的分面统计,用于构建筛选器
654 713
655 ```json 714 ```json
656 { 715 {
@@ -658,9 +717,9 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -658,9 +717,9 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
658 "size": 20, 717 "size": 20,
659 "language": "zh", 718 "language": "zh",
660 "facets": [ 719 "facets": [
661 - "category1_name",  
662 - "category2_name",  
663 - "specifications" 720 + {"field": "category1_name", "size": 15, "type": "terms"},
  721 + {"field": "category2_name", "size": 10, "type": "terms"},
  722 + {"field": "specifications", "size": 10, "type": "terms"}
664 ] 723 ]
665 } 724 }
666 ``` 725 ```
@@ -686,7 +745,8 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -686,7 +745,8 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
686 "facets": [ 745 "facets": [
687 { 746 {
688 "field": "category1_name", 747 "field": "category1_name",
689 - "size": 15 748 + "size": 15,
  749 + "type": "terms"
690 }, 750 },
691 { 751 {
692 "field": "min_price", 752 "field": "min_price",
@@ -698,7 +758,11 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -698,7 +758,11 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
698 {"key": "200+", "from": 200} 758 {"key": "200+", "from": 200}
699 ] 759 ]
700 }, 760 },
701 - "specifications" 761 + {
  762 + "field": "specifications",
  763 + "size": 10,
  764 + "type": "terms"
  765 + }
702 ], 766 ],
703 "sort_by": "min_price", 767 "sort_by": "min_price",
704 "sort_order": "asc" 768 "sort_order": "asc"
@@ -768,18 +832,23 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -768,18 +832,23 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
768 "query": "手机", 832 "query": "手机",
769 "size": 20, 833 "size": 20,
770 "language": "zh", 834 "language": "zh",
771 - "facets": ["specifications"] 835 + "facets": [
  836 + {"field": "specifications", "size": 10, "type": "terms"}
  837 + ]
772 } 838 }
773 ``` 839 ```
774 840
775 -**需求**: 只获取"color"规格的分面统计 841 +**需求**: 只获取"color"和"size"规格的分面统计
776 842
777 ```json 843 ```json
778 { 844 {
779 "query": "手机", 845 "query": "手机",
780 "size": 20, 846 "size": 20,
781 "language": "zh", 847 "language": "zh",
782 - "facets": ["specifications.color", "specifications.size"] 848 + "facets": [
  849 + {"field": "specifications.color", "size": 20, "type": "terms"},
  850 + {"field": "specifications.size", "size": 15, "type": "terms"}
  851 + ]
783 } 852 }
784 ``` 853 ```
785 854
@@ -800,10 +869,10 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \ @@ -800,10 +869,10 @@ curl -X POST &quot;http://120.76.41.98:6002/search/&quot; \
800 } 869 }
801 }, 870 },
802 "facets": [ 871 "facets": [
803 - "category1_name",  
804 - "category2_name",  
805 - "specifications.color",  
806 - "specifications.size" 872 + {"field": "category1_name", "size": 15, "type": "terms"},
  873 + {"field": "category2_name", "size": 10, "type": "terms"},
  874 + {"field": "specifications.color", "size": 20, "type": "terms"},
  875 + {"field": "specifications.size", "size": 15, "type": "terms"}
807 ] 876 ]
808 } 877 }
809 ``` 878 ```
@@ -1006,6 +1075,7 @@ curl &quot;http://localhost:6002/search/12345&quot; @@ -1006,6 +1075,7 @@ curl &quot;http://localhost:6002/search/12345&quot;
1006 | `sku_weights` | long | SKU重量列表(数组) | 1075 | `sku_weights` | long | SKU重量列表(数组) |
1007 | `sku_weight_units` | keyword | SKU重量单位列表(数组) | 1076 | `sku_weight_units` | keyword | SKU重量单位列表(数组) |
1008 | `total_inventory` | long | 总库存 | 1077 | `total_inventory` | long | 总库存 |
  1078 +| `sales` | long | 销量(展示销量) |
1009 | `skus` | nested | SKU详细信息(嵌套对象数组) | 1079 | `skus` | nested | SKU详细信息(嵌套对象数组) |
1010 | `create_time`, `update_time` | date | 创建/更新时间 | 1080 | `create_time`, `update_time` | date | 创建/更新时间 |
1011 | `title_embedding` | dense_vector | 标题向量(1024维,仅用于搜索) | 1081 | `title_embedding` | dense_vector | 标题向量(1024维,仅用于搜索) |
@@ -1038,11 +1108,15 @@ curl &quot;http://localhost:6002/search/12345&quot; @@ -1038,11 +1108,15 @@ curl &quot;http://localhost:6002/search/12345&quot;
1038 1108
1039 #### 排序字段 1109 #### 排序字段
1040 1110
1041 -- `min_price`: 最低价格  
1042 -- `max_price`: 最高价格 1111 +- `price`: 价格(后端自动根据sort_order映射:asc→min_price,desc→max_price)
  1112 +- `sales`: 销量
1043 - `create_time`: 创建时间 1113 - `create_time`: 创建时间
1044 - `update_time`: 更新时间 1114 - `update_time`: 更新时间
1045 -- `relevance_score`: 相关性分数(默认) 1115 +- `relevance_score`: 相关性分数(默认,不指定sort_by时使用)
  1116 +
  1117 +**注意**: 前端只需传 `price`,后端会自动处理:
  1118 +- `sort_by: "price"` + `sort_order: "asc"` → 按 `min_price` 升序(价格从低到高)
  1119 +- `sort_by: "price"` + `sort_order: "desc"` → 按 `max_price` 降序(价格从高到低)
1046 1120
1047 ### 支持的分析器 1121 ### 支持的分析器
1048 1122
docs/系统设计文档.md
@@ -699,45 +699,44 @@ Elasticsearch @@ -699,45 +699,44 @@ Elasticsearch
699 699
700 #### 8.3.2 Facets 配置数据流 700 #### 8.3.2 Facets 配置数据流
701 701
702 -**输入格式**:`List[Union[str, FacetConfig]]` 702 +**输入格式**:`List[FacetConfig]`
703 703
704 -- **简单模式**:字符串列表(字段名),使用默认配置  
705 - ```json  
706 - ["category1_name", "category2_name", "specifications"]  
707 - ```  
708 -  
709 -- **Specifications分面**:  
710 - - 所有规格名称:`"specifications"` - 返回所有name及其value列表  
711 - - 指定规格名称:`"specifications.color"` - 只返回指定name的value列表 704 +**配置对象列表**:所有分面配置必须使用 FacetConfig 对象
  705 +```json
  706 +[
  707 + {
  708 + "field": "category1_name",
  709 + "size": 15,
  710 + "type": "terms"
  711 + },
  712 + {
  713 + "field": "specifications.color",
  714 + "size": 20,
  715 + "type": "terms"
  716 + },
  717 + {
  718 + "field": "min_price",
  719 + "type": "range",
  720 + "ranges": [
  721 + {"key": "0-50", "to": 50},
  722 + {"key": "50-100", "from": 50, "to": 100}
  723 + ]
  724 + }
  725 +]
  726 +```
712 727
713 -- **高级模式**:FacetConfig 对象列表,支持自定义配置  
714 - ```json  
715 - [  
716 - {  
717 - "field": "category1_name",  
718 - "size": 15,  
719 - "type": "terms"  
720 - },  
721 - {  
722 - "field": "min_price",  
723 - "type": "range",  
724 - "ranges": [  
725 - {"key": "0-50", "to": 50},  
726 - {"key": "50-100", "from": 50, "to": 100}  
727 - ]  
728 - },  
729 - "specifications.color" // 指定规格名称的分面  
730 - ]  
731 - ``` 728 +**Specifications 分面支持**:
  729 +- 所有规格名称:`field: "specifications"` - 返回所有 name 及其 value 列表
  730 +- 指定规格名称:`field: "specifications.color"` - 只返回指定 name 的 value 列表
732 731
733 **数据流**: 732 **数据流**:
734 -1. API 层:接收 `List[Union[str, FacetConfig]]`  
735 -2. Searcher 层:透传,不做转换  
736 -3. ES Query Builder:只接受 `str` 或 `FacetConfig`,自动处理两种格式 733 +1. API 层:接收 `List[FacetConfig]`,Pydantic 验证参数
  734 +2. Searcher 层:透传 FacetConfig 对象列表
  735 +3. ES Query Builder:解析 FacetConfig 对象
737 - 检测 `"specifications"` 或 `"specifications.{name}"` 格式 736 - 检测 `"specifications"` 或 `"specifications.{name}"` 格式
738 - - 构建对应的嵌套聚合查询  
739 -4. 输出:转换为 ES 聚合查询(包括specifications嵌套聚合)  
740 -5. Result Formatter:格式化ES聚合结果,处理specifications嵌套结构 737 + - 构建对应的嵌套聚合查询或普通聚合查询
  738 +4. 输出:转换为 ES 聚合查询(包括 specifications 嵌套聚合)
  739 +5. Result Formatter:格式化 ES 聚合结果,处理 specifications 嵌套结构
741 740
742 #### 8.3.3 Range Filters 数据流 741 #### 8.3.3 Range Filters 数据流
743 742
docs/索引字段说明v2.md
@@ -284,11 +284,12 @@ @@ -284,11 +284,12 @@
284 | `sku_weights` | long | 所有 SKU 重量列表(数组) | 从所有 SKU 重量汇总 | 284 | `sku_weights` | long | 所有 SKU 重量列表(数组) | 从所有 SKU 重量汇总 |
285 | `sku_weight_units` | keyword | 所有 SKU 重量单位列表(数组) | 从所有 SKU 重量单位汇总 | 285 | `sku_weight_units` | keyword | 所有 SKU 重量单位列表(数组) | 从所有 SKU 重量单位汇总 |
286 286
287 -### 9. 库存字段 287 +### 9. 库存与销量字段
288 288
289 | 字段名 | ES类型 | 说明 | 数据来源 | 289 | 字段名 | ES类型 | 说明 | 数据来源 |
290 |--------|--------|------|----------| 290 |--------|--------|------|----------|
291 | `total_inventory` | long | 总库存(所有 SKU 库存之和) | 从所有 SKU 库存汇总 | 291 | `total_inventory` | long | 总库存(所有 SKU 库存之和) | 从所有 SKU 库存汇总 |
  292 +| `sales` | long | 销量(展示销量) | MySQL: `shoplazza_product_spu.fake_sales` |
292 293
293 ### 10. SKU 嵌套字段 294 ### 10. SKU 嵌套字段
294 295
@@ -363,6 +364,16 @@ @@ -363,6 +364,16 @@
363 - `vendor_zh.keyword`, `vendor_en.keyword` 364 - `vendor_zh.keyword`, `vendor_en.keyword`
364 - `specifications` (嵌套查询) 365 - `specifications` (嵌套查询)
365 - `min_price`, `max_price` (范围过滤) 366 - `min_price`, `max_price` (范围过滤)
  367 +- `sales` (范围过滤)
  368 +- `total_inventory` (范围过滤)
  369 +
  370 +### 排序字段
  371 +
  372 +- `price`: 价格(前端传入,后端自动映射:asc→min_price,desc→max_price)
  373 +- `sales`: 销量
  374 +- `create_time`: 创建时间
  375 +- `update_time`: 更新时间
  376 +- `relevance_score`: 相关性分数(默认)
366 377
367 ### 分面字段(聚合统计) 378 ### 分面字段(聚合统计)
368 379
frontend/index.html
@@ -94,11 +94,18 @@ @@ -94,11 +94,18 @@
94 <span class="arrow-down" data-field="create_time" data-order="asc" onclick="sortByField('create_time', 'asc')">▼</span> 94 <span class="arrow-down" data-field="create_time" data-order="asc" onclick="sortByField('create_time', 'asc')">▼</span>
95 </span> 95 </span>
96 </button> 96 </button>
97 - <button class="sort-btn" data-sort="min_price"> 97 + <button class="sort-btn" data-sort="price">
98 By Price 98 By Price
99 <span class="sort-arrows"> 99 <span class="sort-arrows">
100 - <span class="arrow-up" data-field="min_price" data-order="asc" onclick="sortByField('min_price', 'asc')">▲</span>  
101 - <span class="arrow-down" data-field="min_price" data-order="desc" onclick="sortByField('min_price', 'desc')">▼</span> 100 + <span class="arrow-up" data-field="price" data-order="asc" onclick="sortByField('price', 'asc')">▲</span>
  101 + <span class="arrow-down" data-field="price" data-order="desc" onclick="sortByField('price', 'desc')">▼</span>
  102 + </span>
  103 + </button>
  104 + <button class="sort-btn" data-sort="sales">
  105 + By Sales
  106 + <span class="sort-arrows">
  107 + <span class="arrow-up" data-field="sales" data-order="desc" onclick="sortByField('sales', 'desc')">▲</span>
  108 + <span class="arrow-down" data-field="sales" data-order="asc" onclick="sortByField('sales', 'asc')">▼</span>
102 </span> 109 </span>
103 </button> 110 </button>
104 111
@@ -135,6 +142,6 @@ @@ -135,6 +142,6 @@
135 <p>SearchEngine © 2025 | API: <span id="apiUrl">Loading...</span></p> 142 <p>SearchEngine © 2025 | API: <span id="apiUrl">Loading...</span></p>
136 </footer> 143 </footer>
137 144
138 - <script src="/static/js/app.js?v=3.4"></script> 145 + <script src="/static/js/app.js?v=3.7"></script>
139 </body> 146 </body>
140 </html> 147 </html>
frontend/static/js/app.js
@@ -86,10 +86,10 @@ async function performSearch(page = 1) { @@ -86,10 +86,10 @@ async function performSearch(page = 1) {
86 86
87 // Define facets (一级分类 + 三个属性分面) 87 // Define facets (一级分类 + 三个属性分面)
88 const facets = [ 88 const facets = [
89 - "category1_name", // 一级分类  
90 - "specifications.color", // 颜色属性  
91 - "specifications.size", // 尺寸属性  
92 - "specifications.material" // 材质属性 89 + { field: "category1_name", size: 15, type: "terms" }, // 一级分类
  90 + { field: "specifications.color", size: 20, type: "terms" }, // 颜色属性
  91 + { field: "specifications.size", size: 15, type: "terms" }, // 尺寸属性
  92 + { field: "specifications.material", size: 10, type: "terms" } // 材质属性
93 ]; 93 ];
94 94
95 // Show loading 95 // Show loading
indexer/spu_transformer.py
@@ -98,6 +98,7 @@ class SPUTransformer: @@ -98,6 +98,7 @@ class SPUTransformer:
98 image_src, image_width, image_height, image_path, image_alt, 98 image_src, image_width, image_height, image_path, image_alt,
99 tags, note, category, category_id, category_google_id, 99 tags, note, category, category_id, category_google_id,
100 category_level, category_path, 100 category_level, category_path,
  101 + fake_sales, display_fake_sales,
101 tenant_id, creator, create_time, updater, update_time, deleted 102 tenant_id, creator, create_time, updater, update_time, deleted
102 FROM shoplazza_product_spu 103 FROM shoplazza_product_spu
103 WHERE tenant_id = :tenant_id AND deleted = 0 104 WHERE tenant_id = :tenant_id AND deleted = 0
@@ -447,6 +448,15 @@ class SPUTransformer: @@ -447,6 +448,15 @@ class SPUTransformer:
447 image_src = f"//{image_src}" if image_src.startswith('//') else image_src 448 image_src = f"//{image_src}" if image_src.startswith('//') else image_src
448 doc['image_url'] = image_src 449 doc['image_url'] = image_src
449 450
  451 + # Sales (fake_sales)
  452 + if pd.notna(spu_row.get('fake_sales')):
  453 + try:
  454 + doc['sales'] = int(spu_row['fake_sales'])
  455 + except (ValueError, TypeError):
  456 + doc['sales'] = 0
  457 + else:
  458 + doc['sales'] = 0
  459 +
450 # Process SKUs and build specifications 460 # Process SKUs and build specifications
451 skus_list = [] 461 skus_list = []
452 prices = [] 462 prices = []
search/es_query_builder.py
@@ -519,7 +519,7 @@ class ESQueryBuilder: @@ -519,7 +519,7 @@ class ESQueryBuilder:
519 519
520 Args: 520 Args:
521 es_query: Existing ES query 521 es_query: Existing ES query
522 - sort_by: Field name for sorting 522 + sort_by: Field name for sorting (支持 'price' 自动映射)
523 sort_order: Sort order: 'asc' or 'desc' 523 sort_order: Sort order: 'asc' or 'desc'
524 524
525 Returns: 525 Returns:
@@ -531,6 +531,13 @@ class ESQueryBuilder: @@ -531,6 +531,13 @@ class ESQueryBuilder:
531 if not sort_order: 531 if not sort_order:
532 sort_order = "desc" 532 sort_order = "desc"
533 533
  534 + # Auto-map 'price' to 'min_price' or 'max_price' based on sort_order
  535 + if sort_by == "price":
  536 + if sort_order.lower() == "asc":
  537 + sort_by = "min_price" # 价格从低到高
  538 + else:
  539 + sort_by = "max_price" # 价格从高到低
  540 +
534 if "sort" not in es_query: 541 if "sort" not in es_query:
535 es_query["sort"] = [] 542 es_query["sort"] = []
536 543
@@ -546,20 +553,18 @@ class ESQueryBuilder: @@ -546,20 +553,18 @@ class ESQueryBuilder:
546 553
547 def build_facets( 554 def build_facets(
548 self, 555 self,
549 - facet_configs: Optional[List[Union[str, 'FacetConfig']]] = None 556 + facet_configs: Optional[List['FacetConfig']] = None
550 ) -> Dict[str, Any]: 557 ) -> Dict[str, Any]:
551 """ 558 """
552 构建分面聚合。 559 构建分面聚合。
553 560
554 - 支持:  
555 - 1. 分类分面:category1_name, category2_name, category3_name, category_name  
556 - 2. specifications分面:嵌套聚合,按name聚合,然后按value聚合  
557 -  
558 Args: 561 Args:
559 - facet_configs: 分面配置列表(标准格式):  
560 - - str: 字段名,使用默认 terms 配置  
561 - - FacetConfig: 详细的分面配置对象  
562 - - 特殊值 "specifications": 构建specifications嵌套分面 562 + facet_configs: 分面配置对象列表
  563 +
  564 + 支持的字段类型:
  565 + - 普通字段: 如 "category1_name"(terms 或 range 类型)
  566 + - specifications: "specifications"(返回所有规格名称及其值)
  567 + - specifications.{name}: 如 "specifications.color"(返回指定规格名称的值)
563 568
564 Returns: 569 Returns:
565 ES aggregations 字典 570 ES aggregations 字典
@@ -570,99 +575,76 @@ class ESQueryBuilder: @@ -570,99 +575,76 @@ class ESQueryBuilder:
570 aggs = {} 575 aggs = {}
571 576
572 for config in facet_configs: 577 for config in facet_configs:
573 - # 特殊处理:specifications嵌套分面  
574 - if isinstance(config, str):  
575 - # 格式1: "specifications" - 返回所有name的分面  
576 - if config == "specifications":  
577 - aggs["specifications_facet"] = {  
578 - "nested": {  
579 - "path": "specifications"  
580 - },  
581 - "aggs": {  
582 - "by_name": {  
583 - "terms": {  
584 - "field": "specifications.name",  
585 - "size": 20,  
586 - "order": {"_count": "desc"}  
587 - },  
588 - "aggs": {  
589 - "value_counts": {  
590 - "terms": {  
591 - "field": "specifications.value",  
592 - "size": 10,  
593 - "order": {"_count": "desc"}  
594 - } 578 + field = config.field
  579 + size = config.size
  580 + facet_type = config.type
  581 +
  582 + # 处理 specifications(所有规格名称)
  583 + if field == "specifications":
  584 + aggs["specifications_facet"] = {
  585 + "nested": {"path": "specifications"},
  586 + "aggs": {
  587 + "by_name": {
  588 + "terms": {
  589 + "field": "specifications.name",
  590 + "size": 20,
  591 + "order": {"_count": "desc"}
  592 + },
  593 + "aggs": {
  594 + "value_counts": {
  595 + "terms": {
  596 + "field": "specifications.value",
  597 + "size": size,
  598 + "order": {"_count": "desc"}
595 } 599 }
596 } 600 }
597 } 601 }
598 } 602 }
599 } 603 }
600 - continue  
601 -  
602 - # 格式2: "specifications.color" 或 "specifications.颜色" - 只返回指定name的value列表  
603 - if config.startswith("specifications."):  
604 - name = config[len("specifications."):]  
605 - agg_name = f"specifications_{name}_facet"  
606 - aggs[agg_name] = {  
607 - "nested": {  
608 - "path": "specifications"  
609 - },  
610 - "aggs": {  
611 - "filter_by_name": {  
612 - "filter": {  
613 - "term": {"specifications.name": name}  
614 - },  
615 - "aggs": {  
616 - "value_counts": {  
617 - "terms": {  
618 - "field": "specifications.value",  
619 - "size": 10,  
620 - "order": {"_count": "desc"}  
621 - } 604 + }
  605 + continue
  606 +
  607 + # 处理 specifications.{name}(指定规格名称)
  608 + if field.startswith("specifications."):
  609 + name = field[len("specifications."):]
  610 + agg_name = f"specifications_{name}_facet"
  611 + aggs[agg_name] = {
  612 + "nested": {"path": "specifications"},
  613 + "aggs": {
  614 + "filter_by_name": {
  615 + "filter": {"term": {"specifications.name": name}},
  616 + "aggs": {
  617 + "value_counts": {
  618 + "terms": {
  619 + "field": "specifications.value",
  620 + "size": size,
  621 + "order": {"_count": "desc"}
622 } 622 }
623 } 623 }
624 } 624 }
625 } 625 }
626 } 626 }
627 - continue 627 + }
  628 + continue
  629 +
  630 + # 处理普通字段
  631 + agg_name = f"{field}_facet"
628 632
629 - # 简单模式:只有字段名(字符串,非specifications)  
630 - if isinstance(config, str):  
631 - field = config  
632 - agg_name = f"{field}_facet" 633 + if facet_type == 'terms':
633 aggs[agg_name] = { 634 aggs[agg_name] = {
634 "terms": { 635 "terms": {
635 "field": field, 636 "field": field,
636 - "size": 10, 637 + "size": size,
637 "order": {"_count": "desc"} 638 "order": {"_count": "desc"}
638 } 639 }
639 } 640 }
640 - continue  
641 -  
642 - # 高级模式:FacetConfig 对象  
643 - else:  
644 - # 此时 config 应该是 FacetConfig 对象  
645 - field = config.field  
646 - facet_type = config.type  
647 - size = config.size  
648 - agg_name = f"{field}_facet"  
649 -  
650 - if facet_type == 'terms': 641 + elif facet_type == 'range':
  642 + if config.ranges:
651 aggs[agg_name] = { 643 aggs[agg_name] = {
652 - "terms": { 644 + "range": {
653 "field": field, 645 "field": field,
654 - "size": size,  
655 - "order": {"_count": "desc"} 646 + "ranges": config.ranges
656 } 647 }
657 } 648 }
658 -  
659 - elif facet_type == 'range':  
660 - if config.ranges:  
661 - aggs[agg_name] = {  
662 - "range": {  
663 - "field": field,  
664 - "ranges": config.ranges  
665 - }  
666 - }  
667 649
668 return aggs 650 return aggs
search/searcher.py
@@ -17,7 +17,7 @@ from .rerank_engine import RerankEngine @@ -17,7 +17,7 @@ from .rerank_engine import RerankEngine
17 from config import SearchConfig 17 from config import SearchConfig
18 from config.utils import get_match_fields_for_index 18 from config.utils import get_match_fields_for_index
19 from context.request_context import RequestContext, RequestContextStage, create_request_context 19 from context.request_context import RequestContext, RequestContextStage, create_request_context
20 -from api.models import FacetResult, FacetValue 20 +from api.models import FacetResult, FacetValue, FacetConfig
21 from api.result_formatter import ResultFormatter 21 from api.result_formatter import ResultFormatter
22 22
23 logger = logging.getLogger(__name__) 23 logger = logging.getLogger(__name__)
@@ -123,7 +123,7 @@ class Searcher: @@ -123,7 +123,7 @@ class Searcher:
123 from_: int = 0, 123 from_: int = 0,
124 filters: Optional[Dict[str, Any]] = None, 124 filters: Optional[Dict[str, Any]] = None,
125 range_filters: Optional[Dict[str, Any]] = None, 125 range_filters: Optional[Dict[str, Any]] = None,
126 - facets: Optional[List[Any]] = None, 126 + facets: Optional[List[FacetConfig]] = None,
127 min_score: Optional[float] = None, 127 min_score: Optional[float] = None,
128 context: Optional[RequestContext] = None, 128 context: Optional[RequestContext] = None,
129 sort_by: Optional[str] = None, 129 sort_by: Optional[str] = None,