Commit 577ec9724155f6d2cc94fa08e4e368a87245fcc0
1 parent
bf89b597
返回给前端的字段、格式适配。主要包括字段配置、前端补充一个语言字段处理title_en title_zh等语言选择、分面信息的提取等
Showing
6 changed files
with
116 additions
and
17 deletions
Show diff stats
api/models.py
| ... | ... | @@ -69,6 +69,10 @@ class SearchRequest(BaseModel): |
| 69 | 69 | query: str = Field(..., description="搜索查询字符串,支持布尔表达式(AND, OR, RANK, ANDNOT)") |
| 70 | 70 | size: int = Field(10, ge=1, le=100, description="返回结果数量") |
| 71 | 71 | from_: int = Field(0, ge=0, alias="from", description="分页偏移量") |
| 72 | + language: Literal["zh", "en"] = Field( | |
| 73 | + "zh", | |
| 74 | + description="响应语言:'zh'(中文)或 'en'(英文),用于选择 title/description/vendor 等多语言字段" | |
| 75 | + ) | |
| 72 | 76 | |
| 73 | 77 | # 过滤器 - 精确匹配和多值匹配 |
| 74 | 78 | filters: Optional[Dict[str, Union[str, int, bool, List[Union[str, int]]]]] = Field( |
| ... | ... | @@ -175,28 +179,53 @@ class FacetResult(BaseModel): |
| 175 | 179 | class SkuResult(BaseModel): |
| 176 | 180 | """SKU 结果""" |
| 177 | 181 | sku_id: str = Field(..., description="SKU ID") |
| 178 | - title: Optional[str] = Field(None, description="SKU标题") | |
| 182 | + # 与 ES nested skus 结构对齐 | |
| 179 | 183 | price: Optional[float] = Field(None, description="价格") |
| 180 | 184 | compare_at_price: Optional[float] = Field(None, description="原价") |
| 181 | - sku: Optional[str] = Field(None, description="SKU编码") | |
| 185 | + sku_code: Optional[str] = Field(None, description="SKU编码") | |
| 182 | 186 | stock: int = Field(0, description="库存数量") |
| 183 | - options: Optional[Dict[str, Any]] = Field(None, description="选项(颜色、尺寸等)") | |
| 187 | + weight: Optional[float] = Field(None, description="重量") | |
| 188 | + weight_unit: Optional[str] = Field(None, description="重量单位") | |
| 189 | + option1_value: Optional[str] = Field(None, description="选项1取值(如颜色)") | |
| 190 | + option2_value: Optional[str] = Field(None, description="选项2取值(如尺码)") | |
| 191 | + option3_value: Optional[str] = Field(None, description="选项3取值") | |
| 192 | + image_src: Optional[str] = Field(None, description="SKU图片地址") | |
| 184 | 193 | |
| 185 | 194 | |
| 186 | 195 | class SpuResult(BaseModel): |
| 187 | 196 | """SPU 搜索结果""" |
| 188 | 197 | spu_id: str = Field(..., description="SPU ID") |
| 189 | 198 | title: Optional[str] = Field(None, description="商品标题") |
| 199 | + brief: Optional[str] = Field(None, description="商品短描述") | |
| 190 | 200 | handle: Optional[str] = Field(None, description="商品handle") |
| 191 | 201 | description: Optional[str] = Field(None, description="商品描述") |
| 192 | 202 | vendor: Optional[str] = Field(None, description="供应商/品牌") |
| 193 | - category: Optional[str] = Field(None, description="类目") | |
| 203 | + category: Optional[str] = Field(None, description="类目(兼容字段,等同于category_name)") | |
| 204 | + category_path: Optional[str] = Field(None, description="类目路径(多级,用于面包屑)") | |
| 205 | + category_name: Optional[str] = Field(None, description="类目名称(展示用)") | |
| 206 | + category_id: Optional[str] = Field(None, description="类目ID") | |
| 207 | + category_level: Optional[int] = Field(None, description="类目层级") | |
| 208 | + category1_name: Optional[str] = Field(None, description="一级类目名称") | |
| 209 | + category2_name: Optional[str] = Field(None, description="二级类目名称") | |
| 210 | + category3_name: Optional[str] = Field(None, description="三级类目名称") | |
| 194 | 211 | tags: Optional[List[str]] = Field(None, description="标签列表") |
| 195 | 212 | price: Optional[float] = Field(None, description="价格(min_price)") |
| 196 | 213 | compare_at_price: Optional[float] = Field(None, description="原价") |
| 197 | 214 | currency: str = Field("USD", description="货币单位") |
| 198 | 215 | image_url: Optional[str] = Field(None, description="主图URL") |
| 199 | 216 | in_stock: bool = Field(True, description="是否有库存") |
| 217 | + # SKU 扁平化信息 | |
| 218 | + sku_prices: Optional[List[float]] = Field(None, description="所有SKU价格列表") | |
| 219 | + sku_weights: Optional[List[int]] = Field(None, description="所有SKU重量列表") | |
| 220 | + sku_weight_units: Optional[List[str]] = Field(None, description="所有SKU重量单位列表") | |
| 221 | + total_inventory: Optional[int] = Field(None, description="总库存") | |
| 222 | + option1_name: Optional[str] = Field(None, description="选项1名称(如颜色)") | |
| 223 | + option2_name: Optional[str] = Field(None, description="选项2名称(如尺码)") | |
| 224 | + option3_name: Optional[str] = Field(None, description="选项3名称") | |
| 225 | + specifications: Optional[List[Dict[str, Any]]] = Field( | |
| 226 | + None, | |
| 227 | + description="规格列表(与 ES specifications 字段对应)" | |
| 228 | + ) | |
| 200 | 229 | skus: List[SkuResult] = Field(default_factory=list, description="SKU列表") |
| 201 | 230 | relevance_score: float = Field(..., ge=0.0, description="相关性分数(ES原始分数)") |
| 202 | 231 | ... | ... |
api/result_formatter.py
| ... | ... | @@ -12,7 +12,8 @@ class ResultFormatter: |
| 12 | 12 | @staticmethod |
| 13 | 13 | def format_search_results( |
| 14 | 14 | es_hits: List[Dict[str, Any]], |
| 15 | - max_score: float = 1.0 | |
| 15 | + max_score: float = 1.0, | |
| 16 | + language: str = "zh" | |
| 16 | 17 | ) -> List[SpuResult]: |
| 17 | 18 | """ |
| 18 | 19 | Convert ES hits to SpuResult list. |
| ... | ... | @@ -25,6 +26,18 @@ class ResultFormatter: |
| 25 | 26 | List of SpuResult objects |
| 26 | 27 | """ |
| 27 | 28 | results = [] |
| 29 | + lang = (language or "zh").lower() | |
| 30 | + if lang not in ("zh", "en"): | |
| 31 | + lang = "en" | |
| 32 | + | |
| 33 | + def pick_lang_field(src: Dict[str, Any], base: str) -> Optional[str]: | |
| 34 | + """从 *_zh / *_en 字段中按语言选择一个值,若目标语言缺失则回退到另一种。""" | |
| 35 | + zh_val = src.get(f"{base}_zh") | |
| 36 | + en_val = src.get(f"{base}_en") | |
| 37 | + if lang == "zh": | |
| 38 | + return zh_val or en_val | |
| 39 | + else: | |
| 40 | + return en_val or zh_val | |
| 28 | 41 | |
| 29 | 42 | for hit in es_hits: |
| 30 | 43 | source = hit.get('_source', {}) |
| ... | ... | @@ -40,6 +53,14 @@ class ResultFormatter: |
| 40 | 53 | except (ValueError, TypeError): |
| 41 | 54 | relevance_score = 0.0 |
| 42 | 55 | |
| 56 | + # Multi-language fields | |
| 57 | + title = pick_lang_field(source, "title") | |
| 58 | + brief = pick_lang_field(source, "brief") | |
| 59 | + description = pick_lang_field(source, "description") | |
| 60 | + vendor = pick_lang_field(source, "vendor") | |
| 61 | + category_path = pick_lang_field(source, "category_path") | |
| 62 | + category_name = pick_lang_field(source, "category_name") | |
| 63 | + | |
| 43 | 64 | # Extract SKUs |
| 44 | 65 | skus = [] |
| 45 | 66 | skus_data = source.get('skus', []) |
| ... | ... | @@ -62,17 +83,33 @@ class ResultFormatter: |
| 62 | 83 | # Build SpuResult |
| 63 | 84 | spu = SpuResult( |
| 64 | 85 | spu_id=str(source.get('spu_id', '')), |
| 65 | - title=source.get('title'), | |
| 86 | + title=title, | |
| 87 | + brief=brief, | |
| 66 | 88 | handle=source.get('handle'), |
| 67 | - description=source.get('description'), | |
| 68 | - vendor=source.get('vendor'), | |
| 69 | - category=source.get('category'), | |
| 89 | + description=description, | |
| 90 | + vendor=vendor, | |
| 91 | + category=category_name, | |
| 92 | + category_path=category_path, | |
| 93 | + category_name=category_name, | |
| 94 | + category_id=source.get('category_id'), | |
| 95 | + category_level=source.get('category_level'), | |
| 96 | + category1_name=source.get('category1_name'), | |
| 97 | + category2_name=source.get('category2_name'), | |
| 98 | + category3_name=source.get('category3_name'), | |
| 70 | 99 | tags=source.get('tags'), |
| 71 | 100 | price=source.get('min_price'), |
| 72 | 101 | compare_at_price=source.get('compare_at_price'), |
| 73 | 102 | currency="USD", # Default currency |
| 74 | 103 | image_url=source.get('image_url'), |
| 75 | 104 | in_stock=in_stock, |
| 105 | + sku_prices=source.get('sku_prices'), | |
| 106 | + sku_weights=source.get('sku_weights'), | |
| 107 | + sku_weight_units=source.get('sku_weight_units'), | |
| 108 | + total_inventory=source.get('total_inventory'), | |
| 109 | + option1_name=source.get('option1_name'), | |
| 110 | + option2_name=source.get('option2_name'), | |
| 111 | + option3_name=source.get('option3_name'), | |
| 112 | + specifications=source.get('specifications'), | |
| 76 | 113 | skus=skus, |
| 77 | 114 | relevance_score=relevance_score |
| 78 | 115 | ) | ... | ... |
api/routes/search.py
| ... | ... | @@ -94,7 +94,8 @@ async def search(request: SearchRequest, http_request: Request): |
| 94 | 94 | context=context, |
| 95 | 95 | sort_by=request.sort_by, |
| 96 | 96 | sort_order=request.sort_order, |
| 97 | - debug=request.debug | |
| 97 | + debug=request.debug, | |
| 98 | + language=request.language, | |
| 98 | 99 | ) |
| 99 | 100 | |
| 100 | 101 | # Include performance summary in response | ... | ... |
docs/ES常用表达式.md
| ... | ... | @@ -7,3 +7,14 @@ GET /search_products/_search |
| 7 | 7 | } |
| 8 | 8 | } |
| 9 | 9 | |
| 10 | + | |
| 11 | +curl -u 'essa:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products/_search?pretty' -H 'Content-Type: application/json' -d '{ | |
| 12 | + "size": 5, | |
| 13 | + "query": { | |
| 14 | + "bool": { | |
| 15 | + "filter": [ | |
| 16 | + { "term": { "tenant_id": "162" } } | |
| 17 | + ] | |
| 18 | + } | |
| 19 | + } | |
| 20 | + }' | |
| 10 | 21 | \ No newline at end of file | ... | ... |
search/query_config.py
| ... | ... | @@ -37,17 +37,32 @@ DOMAIN_FIELDS: Dict[str, List[str]] = { |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | # Source fields to return in search results |
| 40 | +# 注意:为了在后端做多语言选择,_zh / _en 字段仍然需要从 ES 取出, | |
| 41 | +# 但不会原样透出给前端,而是统一映射到 title / description / vendor 等字段。 | |
| 40 | 42 | SOURCE_FIELDS = [ |
| 43 | + # 基本标识 | |
| 41 | 44 | "tenant_id", |
| 42 | 45 | "spu_id", |
| 46 | + "create_time", | |
| 47 | + "update_time", | |
| 48 | + | |
| 49 | + # 多语言文本字段(仅用于后端选择,不直接返回给前端) | |
| 43 | 50 | "title_zh", |
| 51 | + "title_en", | |
| 44 | 52 | "brief_zh", |
| 53 | + "brief_en", | |
| 45 | 54 | "description_zh", |
| 55 | + "description_en", | |
| 46 | 56 | "vendor_zh", |
| 47 | - "tags", | |
| 48 | - "image_url", | |
| 57 | + "vendor_en", | |
| 49 | 58 | "category_path_zh", |
| 59 | + "category_path_en", | |
| 50 | 60 | "category_name_zh", |
| 61 | + "category_name_en", | |
| 62 | + | |
| 63 | + # 语言无关字段(直接返回给前端) | |
| 64 | + "tags", | |
| 65 | + "image_url", | |
| 51 | 66 | "category_id", |
| 52 | 67 | "category_name", |
| 53 | 68 | "category_level", |
| ... | ... | @@ -60,11 +75,12 @@ SOURCE_FIELDS = [ |
| 60 | 75 | "min_price", |
| 61 | 76 | "max_price", |
| 62 | 77 | "compare_at_price", |
| 78 | + "sku_prices", | |
| 79 | + "sku_weights", | |
| 80 | + "sku_weight_units", | |
| 63 | 81 | "total_inventory", |
| 64 | - "create_time", | |
| 65 | - "update_time", | |
| 66 | 82 | "skus", |
| 67 | - "specifications" | |
| 83 | + "specifications", | |
| 68 | 84 | ] |
| 69 | 85 | |
| 70 | 86 | # Query processing settings | ... | ... |
search/searcher.py
| ... | ... | @@ -134,7 +134,8 @@ class Searcher: |
| 134 | 134 | context: Optional[RequestContext] = None, |
| 135 | 135 | sort_by: Optional[str] = None, |
| 136 | 136 | sort_order: Optional[str] = "desc", |
| 137 | - debug: bool = False | |
| 137 | + debug: bool = False, | |
| 138 | + language: str = "zh", | |
| 138 | 139 | ) -> SearchResult: |
| 139 | 140 | """ |
| 140 | 141 | Execute search query (外部友好格式). |
| ... | ... | @@ -373,7 +374,11 @@ class Searcher: |
| 373 | 374 | max_score = es_response.get('hits', {}).get('max_score') or 0.0 |
| 374 | 375 | |
| 375 | 376 | # Format results using ResultFormatter |
| 376 | - formatted_results = ResultFormatter.format_search_results(es_hits, max_score) | |
| 377 | + formatted_results = ResultFormatter.format_search_results( | |
| 378 | + es_hits, | |
| 379 | + max_score, | |
| 380 | + language=language | |
| 381 | + ) | |
| 377 | 382 | |
| 378 | 383 | # Format facets |
| 379 | 384 | standardized_facets = None | ... | ... |