Commit a3a5d41b84486128bff6d2d39d8c7eb365258040
1 parent
c4263d93
(sku_filter_dimension 支持多维度组合去重)
后端请求模型变更(api/models.py)
SearchRequest.sku_filter_dimension 从 Optional[str] 改为 Optional[List[str]]。
语义:列表表示一个或多个“维度标签”,例如:
单维度:["color"]、["option1"]
多维度:["color", "size"]、["option1", "option2"]
描述更新为:对 维度组合进行分组,每个组合只保留一个 SKU。
结果格式化与去重逻辑(api/result_formatter.py)
ResultFormatter.format_search_results(..., sku_filter_dimension: Optional[List[str]] = None),调用处已同步更新。
单维度旧逻辑升级为多维度逻辑:
新方法:_filter_skus_by_dimensions(skus, dimensions, option1_name, option2_name, option3_name, specifications)。
维度解析规则(按顺序处理,并去重):
若维度是 option1 / option2 / option3 → 对应 option1_value / option2_value / option3_value。
否则,将维度字符串转小写后,分别与 option1_name / option2_name / option3_name 对比,相等则映射到对应的 option*_value。
未能映射到任何字段的维度会被忽略。
对每个 SKU:
按解析出的字段列表(例如 ["option1_value", "option2_value"])取值,组成 key,如 ("red", "L");None 用空串 ""。
按 key 分组,每个 key 只保留遇到的第一个 SKU。
若列表为空或所有维度都无法解析,则 不做过滤,返回原始 skus。
Searcher 参数类型同步(search/searcher.py)
Searcher.search(...) 中 sku_filter_dimension 参数类型从 Optional[str] 改为 Optional[List[str]]。
传给 ResultFormatter.format_search_results 时,直接传该列表。
前端参数格式调整(frontend/static/js/app.js)
输入框 #skuFilterDimension 依旧是一个文本框,但解析方式改为:
函数 getSkuFilterDimension():
读取文本,如:"color" 或 "color,size" 或 "option1, color"。
用逗号 , 拆分,trim() 后过滤空串,返回 字符串数组,例如:
"color" → ["color"]
"color,size" → ["color", "size"]
若最终数组为空,则返回 null。
搜索请求体中仍使用字段名 sku_filter_dimension,但现在值是 string[] 或 null:
body: JSON.stringify({ // ... sku_filter_dimension: skuFilterDimension, // 例如 ["color", "size"] debug: state.debug })
文档更新(docs/搜索API对接指南.md)
请求体示例中的类型由:
"sku_filter_dimension": "string"
改为:
"sku_filter_dimension": ["string"]
参数表中:
从 string 改为 array[string],说明为“维度列表,按组合分组,每个组合保留一个 SKU”。
功能说明章节“SKU筛选维度 (sku_filter_dimension)”已调整为 列表语义 + 组合去重,并补充了示例:
单维度:
{ "query": "芭比娃娃", "sku_filter_dimension": ["color"] }
多维度组合:
{ "query": "芭比娃娃", "sku_filter_dimension": ["color", "size"] }
使用方式总结
单维度去重(保持旧行为的等价写法)
旧:"sku_filter_dimension": "color"
新:"sku_filter_dimension": ["color"]
多维度组合去重(你新提的需求)
例如希望“每个 SPU 下,同一颜色+尺码组合只保留一个 SKU”:
{ "query": "芭比娃娃", "sku_filter_dimension": ["color", "size"] }
Showing
6 changed files
with
90 additions
and
68 deletions
Show diff stats
api/models.py
| @@ -146,9 +146,14 @@ class SearchRequest(BaseModel): | @@ -146,9 +146,14 @@ class SearchRequest(BaseModel): | ||
| 146 | debug: bool = Field(False, description="是否返回调试信息") | 146 | debug: bool = Field(False, description="是否返回调试信息") |
| 147 | 147 | ||
| 148 | # SKU筛选参数 | 148 | # SKU筛选参数 |
| 149 | - sku_filter_dimension: Optional[str] = Field( | 149 | + sku_filter_dimension: Optional[List[str]] = Field( |
| 150 | None, | 150 | None, |
| 151 | - description="子SKU筛选维度(店铺配置)。指定后,每个SPU下的SKU将按该维度分组,每组选择第一个SKU返回。例如:'color'表示按颜色分组,每种颜色选一款。支持的值:'option1'、'option2'、'option3'或specifications中的name(如'color'、'size')" | 151 | + description=( |
| 152 | + "子SKU筛选维度(店铺配置),为字符串列表。" | ||
| 153 | + "指定后,每个SPU下的SKU将按这些维度的组合进行分组,每个维度组合只保留一个SKU返回。" | ||
| 154 | + "例如:['color'] 表示按颜色分组,每种颜色选一款;['color', 'size'] 表示按颜色+尺码组合分组。" | ||
| 155 | + "支持的值:'option1'、'option2'、'option3' 或选项名称(如 'color'、'size',将通过 option1_name/2_name/3_name 匹配)。" | ||
| 156 | + ) | ||
| 152 | ) | 157 | ) |
| 153 | 158 | ||
| 154 | # 个性化参数(预留) | 159 | # 个性化参数(预留) |
api/result_formatter.py
| @@ -14,7 +14,7 @@ class ResultFormatter: | @@ -14,7 +14,7 @@ class ResultFormatter: | ||
| 14 | es_hits: List[Dict[str, Any]], | 14 | es_hits: List[Dict[str, Any]], |
| 15 | max_score: float = 1.0, | 15 | max_score: float = 1.0, |
| 16 | language: str = "zh", | 16 | language: str = "zh", |
| 17 | - sku_filter_dimension: Optional[str] = None | 17 | + sku_filter_dimension: Optional[List[str]] = None |
| 18 | ) -> List[SpuResult]: | 18 | ) -> List[SpuResult]: |
| 19 | """ | 19 | """ |
| 20 | Convert ES hits to SpuResult list. | 20 | Convert ES hits to SpuResult list. |
| @@ -85,10 +85,10 @@ class ResultFormatter: | @@ -85,10 +85,10 @@ class ResultFormatter: | ||
| 85 | ) | 85 | ) |
| 86 | skus.append(sku) | 86 | skus.append(sku) |
| 87 | 87 | ||
| 88 | - # Apply SKU filtering if dimension is specified | 88 | + # Apply SKU filtering if dimension list is specified |
| 89 | if sku_filter_dimension and skus: | 89 | if sku_filter_dimension and skus: |
| 90 | - skus = ResultFormatter._filter_skus_by_dimension( | ||
| 91 | - skus, | 90 | + skus = ResultFormatter._filter_skus_by_dimensions( |
| 91 | + skus, | ||
| 92 | sku_filter_dimension, | 92 | sku_filter_dimension, |
| 93 | source.get('option1_name'), | 93 | source.get('option1_name'), |
| 94 | source.get('option2_name'), | 94 | source.get('option2_name'), |
| @@ -138,22 +138,22 @@ class ResultFormatter: | @@ -138,22 +138,22 @@ class ResultFormatter: | ||
| 138 | return results | 138 | return results |
| 139 | 139 | ||
| 140 | @staticmethod | 140 | @staticmethod |
| 141 | - def _filter_skus_by_dimension( | 141 | + def _filter_skus_by_dimensions( |
| 142 | skus: List[SkuResult], | 142 | skus: List[SkuResult], |
| 143 | - dimension: str, | 143 | + dimensions: List[str], |
| 144 | option1_name: Optional[str] = None, | 144 | option1_name: Optional[str] = None, |
| 145 | option2_name: Optional[str] = None, | 145 | option2_name: Optional[str] = None, |
| 146 | option3_name: Optional[str] = None, | 146 | option3_name: Optional[str] = None, |
| 147 | specifications: Optional[List[Dict[str, Any]]] = None | 147 | specifications: Optional[List[Dict[str, Any]]] = None |
| 148 | ) -> List[SkuResult]: | 148 | ) -> List[SkuResult]: |
| 149 | """ | 149 | """ |
| 150 | - Filter SKUs by dimension, keeping only one SKU per dimension value. | 150 | + Filter SKUs by one or more dimensions, keeping only one SKU per dimension value combination. |
| 151 | 151 | ||
| 152 | Args: | 152 | Args: |
| 153 | skus: List of SKU results to filter | 153 | skus: List of SKU results to filter |
| 154 | - dimension: Filter dimension, can be: | 154 | + dimensions: Filter dimensions, each dimension can be: |
| 155 | - 'option1', 'option2', 'option3': Direct option field | 155 | - 'option1', 'option2', 'option3': Direct option field |
| 156 | - - A specification name (e.g., 'color', 'size'): Match by option name | 156 | + - A specification/option name (e.g., 'color', 'size'): Match by option name |
| 157 | option1_name: Name of option1 (e.g., 'color') | 157 | option1_name: Name of option1 (e.g., 'color') |
| 158 | option2_name: Name of option2 (e.g., 'size') | 158 | option2_name: Name of option2 (e.g., 'size') |
| 159 | option3_name: Name of option3 | 159 | option3_name: Name of option3 |
| @@ -162,54 +162,59 @@ class ResultFormatter: | @@ -162,54 +162,59 @@ class ResultFormatter: | ||
| 162 | Returns: | 162 | Returns: |
| 163 | Filtered list of SKUs (one per dimension value) | 163 | Filtered list of SKUs (one per dimension value) |
| 164 | """ | 164 | """ |
| 165 | - if not skus: | 165 | + if not skus or not dimensions: |
| 166 | return skus | 166 | return skus |
| 167 | - | ||
| 168 | - # Determine which field to use for filtering | ||
| 169 | - filter_field = None | ||
| 170 | - | ||
| 171 | - # Direct option field (option1, option2, option3) | ||
| 172 | - if dimension.lower() == 'option1': | ||
| 173 | - filter_field = 'option1_value' | ||
| 174 | - elif dimension.lower() == 'option2': | ||
| 175 | - filter_field = 'option2_value' | ||
| 176 | - elif dimension.lower() == 'option3': | ||
| 177 | - filter_field = 'option3_value' | ||
| 178 | - else: | ||
| 179 | - # Try to match by option name | ||
| 180 | - dimension_lower = dimension.lower() | ||
| 181 | - if option1_name and option1_name.lower() == dimension_lower: | ||
| 182 | - filter_field = 'option1_value' | ||
| 183 | - elif option2_name and option2_name.lower() == dimension_lower: | ||
| 184 | - filter_field = 'option2_value' | ||
| 185 | - elif option3_name and option3_name.lower() == dimension_lower: | ||
| 186 | - filter_field = 'option3_value' | ||
| 187 | - | ||
| 188 | - # If no matching field found, return all SKUs (no filtering) | ||
| 189 | - if not filter_field: | ||
| 190 | - return skus | ||
| 191 | - | ||
| 192 | - # Group SKUs by dimension value and select first one from each group | ||
| 193 | - dimension_groups: Dict[str, SkuResult] = {} | ||
| 194 | - | 167 | + |
| 168 | + # Resolve each dimension to an underlying SKU field (option1_value / option2_value / option3_value) | ||
| 169 | + filter_fields: List[str] = [] | ||
| 170 | + | ||
| 171 | + for dim in dimensions: | ||
| 172 | + if not dim: | ||
| 173 | + continue | ||
| 174 | + dim_lower = dim.lower() | ||
| 175 | + | ||
| 176 | + field_name: Optional[str] = None | ||
| 177 | + # Direct option field (option1, option2, option3) | ||
| 178 | + if dim_lower == 'option1': | ||
| 179 | + field_name = 'option1_value' | ||
| 180 | + elif dim_lower == 'option2': | ||
| 181 | + field_name = 'option2_value' | ||
| 182 | + elif dim_lower == 'option3': | ||
| 183 | + field_name = 'option3_value' | ||
| 184 | + else: | ||
| 185 | + # Try to match by option name | ||
| 186 | + if option1_name and option1_name.lower() == dim_lower: | ||
| 187 | + field_name = 'option1_value' | ||
| 188 | + elif option2_name and option2_name.lower() == dim_lower: | ||
| 189 | + field_name = 'option2_value' | ||
| 190 | + elif option3_name and option3_name.lower() == dim_lower: | ||
| 191 | + field_name = 'option3_value' | ||
| 192 | + | ||
| 193 | + if field_name and field_name not in filter_fields: | ||
| 194 | + filter_fields.append(field_name) | ||
| 195 | + | ||
| 196 | + # If no matching field found for all dimensions, do not return any child SKUs | ||
| 197 | + if not filter_fields: | ||
| 198 | + return [] | ||
| 199 | + | ||
| 200 | + # Group SKUs by dimension value combination and select first one from each group | ||
| 201 | + dimension_groups: Dict[tuple, SkuResult] = {} | ||
| 202 | + | ||
| 195 | for sku in skus: | 203 | for sku in skus: |
| 196 | - # Get dimension value from the determined field | ||
| 197 | - dimension_value = None | ||
| 198 | - if filter_field == 'option1_value': | ||
| 199 | - dimension_value = sku.option1_value | ||
| 200 | - elif filter_field == 'option2_value': | ||
| 201 | - dimension_value = sku.option2_value | ||
| 202 | - elif filter_field == 'option3_value': | ||
| 203 | - dimension_value = sku.option3_value | ||
| 204 | - | ||
| 205 | - # Use empty string as key for None values | ||
| 206 | - key = str(dimension_value) if dimension_value is not None else '' | ||
| 207 | - | ||
| 208 | - # Keep first SKU for each dimension value | 204 | + # Build key as combination of all dimension values |
| 205 | + key_values: List[str] = [] | ||
| 206 | + for field in filter_fields: | ||
| 207 | + dimension_value = getattr(sku, field, None) | ||
| 208 | + # Use empty string as key part for None values | ||
| 209 | + key_values.append(str(dimension_value) if dimension_value is not None else '') | ||
| 210 | + | ||
| 211 | + key = tuple(key_values) | ||
| 212 | + | ||
| 213 | + # Keep first SKU for each dimension combination | ||
| 209 | if key not in dimension_groups: | 214 | if key not in dimension_groups: |
| 210 | dimension_groups[key] = sku | 215 | dimension_groups[key] = sku |
| 211 | - | ||
| 212 | - # Return filtered SKUs (one per dimension value) | 216 | + |
| 217 | + # Return filtered SKUs (one per dimension combination) | ||
| 213 | return list(dimension_groups.values()) | 218 | return list(dimension_groups.values()) |
| 214 | 219 | ||
| 215 | @staticmethod | 220 | @staticmethod |
docs/搜索API对接指南.md
| @@ -104,7 +104,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | @@ -104,7 +104,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | ||
| 104 | "sort_by": "string", | 104 | "sort_by": "string", |
| 105 | "sort_order": "desc", | 105 | "sort_order": "desc", |
| 106 | "min_score": 0.0, | 106 | "min_score": 0.0, |
| 107 | - "sku_filter_dimension": "string", | 107 | + "sku_filter_dimension": ["string"], |
| 108 | "debug": false, | 108 | "debug": false, |
| 109 | "user_id": "string", | 109 | "user_id": "string", |
| 110 | "session_id": "string" | 110 | "session_id": "string" |
| @@ -127,7 +127,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | @@ -127,7 +127,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | ||
| 127 | | `sort_by` | string | N | null | 排序字段名(如 `min_price`, `max_price`) | | 127 | | `sort_by` | string | N | null | 排序字段名(如 `min_price`, `max_price`) | |
| 128 | | `sort_order` | string | N | "desc" | 排序方向:`asc`(升序)或 `desc`(降序) | | 128 | | `sort_order` | string | N | "desc" | 排序方向:`asc`(升序)或 `desc`(降序) | |
| 129 | | `min_score` | float | N | null | 最小相关性分数阈值 | | 129 | | `min_score` | float | N | null | 最小相关性分数阈值 | |
| 130 | -| `sku_filter_dimension` | string | N | null | 子SKU筛选维度(店铺配置)。指定后,每个SPU下的SKU将按该维度分组,每组选择第一个SKU返回。支持的值:`option1`、`option2`、`option3` 或 specifications 中的 name(如 `color`、`size`)。详见下文说明 | | 130 | +| `sku_filter_dimension` | array[string] | N | null | 子SKU筛选维度列表(店铺配置)。指定后,每个SPU下的SKU将按这些维度的组合进行分组,每个组合只返回第一个SKU。支持的值:`option1`、`option2`、`option3` 或选项名称(如 `color`、`size`)。详见下文说明 | |
| 131 | | `debug` | boolean | N | false | 是否返回调试信息 | | 131 | | `debug` | boolean | N | false | 是否返回调试信息 | |
| 132 | | `user_id` | string | N | null | 用户ID(用于个性化,预留) | | 132 | | `user_id` | string | N | null | 用户ID(用于个性化,预留) | |
| 133 | | `session_id` | string | N | null | 会话ID(用于分析,预留) | | 133 | | `session_id` | string | N | null | 会话ID(用于分析,预留) | |
| @@ -349,7 +349,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | @@ -349,7 +349,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | ||
| 349 | ### SKU筛选维度 (sku_filter_dimension) | 349 | ### SKU筛选维度 (sku_filter_dimension) |
| 350 | 350 | ||
| 351 | **功能说明**: | 351 | **功能说明**: |
| 352 | -`sku_filter_dimension` 用于控制每个SPU下返回的SKU数量。当指定此参数后,系统会按指定维度对SKU进行分组,每个分组只返回第一个SKU(从简实现,选择该维度下的第一款)。 | 352 | +`sku_filter_dimension` 用于控制每个SPU下返回的SKU数量,为字符串列表。当指定此参数后,系统会按指定维度**组合**对SKU进行分组,每个维度组合只返回第一个SKU(从简实现,选择该组合下的第一款)。 |
| 353 | 353 | ||
| 354 | **使用场景**: | 354 | **使用场景**: |
| 355 | - 店铺配置了SKU筛选维度(如 `color`),希望每个SPU下每种颜色只显示一个SKU | 355 | - 店铺配置了SKU筛选维度(如 `color`),希望每个SPU下每种颜色只显示一个SKU |
| @@ -360,8 +360,8 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | @@ -360,8 +360,8 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | ||
| 360 | 1. **直接选项字段**: `option1`、`option2`、`option3` | 360 | 1. **直接选项字段**: `option1`、`option2`、`option3` |
| 361 | - 直接使用对应的 `option1_value`、`option2_value`、`option3_value` 字段进行分组 | 361 | - 直接使用对应的 `option1_value`、`option2_value`、`option3_value` 字段进行分组 |
| 362 | 362 | ||
| 363 | -2. **规格名称**: 通过 `option1_name`、`option2_name`、`option3_name` 匹配 | ||
| 364 | - - 例如:如果 `option1_name` 为 `"color"`,则可以使用 `sku_filter_dimension: "color"` 来按颜色分组 | 363 | +2. **规格/选项名称**: 通过 `option1_name`、`option2_name`、`option3_name` 匹配 |
| 364 | + - 例如:如果 `option1_name` 为 `"color"`,则可以使用 `sku_filter_dimension: ["color"]` 来按颜色分组 | ||
| 365 | 365 | ||
| 366 | **示例**: | 366 | **示例**: |
| 367 | 367 | ||
| @@ -369,7 +369,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | @@ -369,7 +369,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | ||
| 369 | ```json | 369 | ```json |
| 370 | { | 370 | { |
| 371 | "query": "芭比娃娃", | 371 | "query": "芭比娃娃", |
| 372 | - "sku_filter_dimension": "color" | 372 | + "sku_filter_dimension": ["color"] |
| 373 | } | 373 | } |
| 374 | ``` | 374 | ``` |
| 375 | 375 | ||
| @@ -377,7 +377,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | @@ -377,7 +377,7 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | ||
| 377 | ```json | 377 | ```json |
| 378 | { | 378 | { |
| 379 | "query": "芭比娃娃", | 379 | "query": "芭比娃娃", |
| 380 | - "sku_filter_dimension": "option1" | 380 | + "sku_filter_dimension": ["option1"] |
| 381 | } | 381 | } |
| 382 | ``` | 382 | ``` |
| 383 | 383 | ||
| @@ -385,7 +385,15 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | @@ -385,7 +385,15 @@ curl -X POST "http://120.76.41.98:6002/search/" \ | ||
| 385 | ```json | 385 | ```json |
| 386 | { | 386 | { |
| 387 | "query": "芭比娃娃", | 387 | "query": "芭比娃娃", |
| 388 | - "sku_filter_dimension": "option2" | 388 | + "sku_filter_dimension": ["option2"] |
| 389 | +} | ||
| 390 | +``` | ||
| 391 | + | ||
| 392 | +**按颜色 + 尺寸组合筛选(假设 option1_name = "color", option2_name = "size")**: | ||
| 393 | +```json | ||
| 394 | +{ | ||
| 395 | + "query": "芭比娃娃", | ||
| 396 | + "sku_filter_dimension": ["color", "size"] | ||
| 389 | } | 397 | } |
| 390 | ``` | 398 | ``` |
| 391 | 399 |
frontend/index.html
| @@ -135,6 +135,6 @@ | @@ -135,6 +135,6 @@ | ||
| 135 | <p>SearchEngine © 2025 | API: <span id="apiUrl">Loading...</span></p> | 135 | <p>SearchEngine © 2025 | API: <span id="apiUrl">Loading...</span></p> |
| 136 | </footer> | 136 | </footer> |
| 137 | 137 | ||
| 138 | - <script src="/static/js/app.js?v=3.3"></script> | 138 | + <script src="/static/js/app.js?v=3.4"></script> |
| 139 | </body> | 139 | </body> |
| 140 | </html> | 140 | </html> |
frontend/static/js/app.js
| @@ -14,13 +14,17 @@ function getTenantId() { | @@ -14,13 +14,17 @@ function getTenantId() { | ||
| 14 | return '1'; // Default fallback | 14 | return '1'; // Default fallback |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | -// Get sku_filter_dimension from input | 17 | +// Get sku_filter_dimension (as list) from input |
| 18 | function getSkuFilterDimension() { | 18 | function getSkuFilterDimension() { |
| 19 | const skuFilterInput = document.getElementById('skuFilterDimension'); | 19 | const skuFilterInput = document.getElementById('skuFilterDimension'); |
| 20 | if (skuFilterInput) { | 20 | if (skuFilterInput) { |
| 21 | const value = skuFilterInput.value.trim(); | 21 | const value = skuFilterInput.value.trim(); |
| 22 | - // Return the value if not empty, otherwise return null | ||
| 23 | - return value.length > 0 ? value : null; | 22 | + if (!value.length) { |
| 23 | + return null; | ||
| 24 | + } | ||
| 25 | + // 支持用逗号分隔多个维度,例如:color,size 或 option1,color | ||
| 26 | + const parts = value.split(',').map(v => v.trim()).filter(v => v.length > 0); | ||
| 27 | + return parts.length > 0 ? parts : null; | ||
| 24 | } | 28 | } |
| 25 | return null; | 29 | return null; |
| 26 | } | 30 | } |
search/searcher.py
| @@ -130,7 +130,7 @@ class Searcher: | @@ -130,7 +130,7 @@ class Searcher: | ||
| 130 | sort_order: Optional[str] = "desc", | 130 | sort_order: Optional[str] = "desc", |
| 131 | debug: bool = False, | 131 | debug: bool = False, |
| 132 | language: str = "zh", | 132 | language: str = "zh", |
| 133 | - sku_filter_dimension: Optional[str] = None, | 133 | + sku_filter_dimension: Optional[List[str]] = None, |
| 134 | ) -> SearchResult: | 134 | ) -> SearchResult: |
| 135 | """ | 135 | """ |
| 136 | Execute search query (外部友好格式). | 136 | Execute search query (外部友好格式). |