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,6 +69,10 @@ class SearchRequest(BaseModel): | ||
| 69 | query: str = Field(..., description="搜索查询字符串,支持布尔表达式(AND, OR, RANK, ANDNOT)") | 69 | query: str = Field(..., description="搜索查询字符串,支持布尔表达式(AND, OR, RANK, ANDNOT)") |
| 70 | size: int = Field(10, ge=1, le=100, description="返回结果数量") | 70 | size: int = Field(10, ge=1, le=100, description="返回结果数量") |
| 71 | from_: int = Field(0, ge=0, alias="from", description="分页偏移量") | 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 | filters: Optional[Dict[str, Union[str, int, bool, List[Union[str, int]]]]] = Field( | 78 | filters: Optional[Dict[str, Union[str, int, bool, List[Union[str, int]]]]] = Field( |
| @@ -175,28 +179,53 @@ class FacetResult(BaseModel): | @@ -175,28 +179,53 @@ class FacetResult(BaseModel): | ||
| 175 | class SkuResult(BaseModel): | 179 | class SkuResult(BaseModel): |
| 176 | """SKU 结果""" | 180 | """SKU 结果""" |
| 177 | sku_id: str = Field(..., description="SKU ID") | 181 | sku_id: str = Field(..., description="SKU ID") |
| 178 | - title: Optional[str] = Field(None, description="SKU标题") | 182 | + # 与 ES nested skus 结构对齐 |
| 179 | price: Optional[float] = Field(None, description="价格") | 183 | price: Optional[float] = Field(None, description="价格") |
| 180 | compare_at_price: Optional[float] = Field(None, description="原价") | 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 | stock: int = Field(0, description="库存数量") | 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 | class SpuResult(BaseModel): | 195 | class SpuResult(BaseModel): |
| 187 | """SPU 搜索结果""" | 196 | """SPU 搜索结果""" |
| 188 | spu_id: str = Field(..., description="SPU ID") | 197 | spu_id: str = Field(..., description="SPU ID") |
| 189 | title: Optional[str] = Field(None, description="商品标题") | 198 | title: Optional[str] = Field(None, description="商品标题") |
| 199 | + brief: Optional[str] = Field(None, description="商品短描述") | ||
| 190 | handle: Optional[str] = Field(None, description="商品handle") | 200 | handle: Optional[str] = Field(None, description="商品handle") |
| 191 | description: Optional[str] = Field(None, description="商品描述") | 201 | description: Optional[str] = Field(None, description="商品描述") |
| 192 | vendor: Optional[str] = Field(None, description="供应商/品牌") | 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 | tags: Optional[List[str]] = Field(None, description="标签列表") | 211 | tags: Optional[List[str]] = Field(None, description="标签列表") |
| 195 | price: Optional[float] = Field(None, description="价格(min_price)") | 212 | price: Optional[float] = Field(None, description="价格(min_price)") |
| 196 | compare_at_price: Optional[float] = Field(None, description="原价") | 213 | compare_at_price: Optional[float] = Field(None, description="原价") |
| 197 | currency: str = Field("USD", description="货币单位") | 214 | currency: str = Field("USD", description="货币单位") |
| 198 | image_url: Optional[str] = Field(None, description="主图URL") | 215 | image_url: Optional[str] = Field(None, description="主图URL") |
| 199 | in_stock: bool = Field(True, description="是否有库存") | 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 | skus: List[SkuResult] = Field(default_factory=list, description="SKU列表") | 229 | skus: List[SkuResult] = Field(default_factory=list, description="SKU列表") |
| 201 | relevance_score: float = Field(..., ge=0.0, description="相关性分数(ES原始分数)") | 230 | relevance_score: float = Field(..., ge=0.0, description="相关性分数(ES原始分数)") |
| 202 | 231 |
api/result_formatter.py
| @@ -12,7 +12,8 @@ class ResultFormatter: | @@ -12,7 +12,8 @@ class ResultFormatter: | ||
| 12 | @staticmethod | 12 | @staticmethod |
| 13 | def format_search_results( | 13 | def format_search_results( |
| 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 | ) -> List[SpuResult]: | 17 | ) -> List[SpuResult]: |
| 17 | """ | 18 | """ |
| 18 | Convert ES hits to SpuResult list. | 19 | Convert ES hits to SpuResult list. |
| @@ -25,6 +26,18 @@ class ResultFormatter: | @@ -25,6 +26,18 @@ class ResultFormatter: | ||
| 25 | List of SpuResult objects | 26 | List of SpuResult objects |
| 26 | """ | 27 | """ |
| 27 | results = [] | 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 | for hit in es_hits: | 42 | for hit in es_hits: |
| 30 | source = hit.get('_source', {}) | 43 | source = hit.get('_source', {}) |
| @@ -40,6 +53,14 @@ class ResultFormatter: | @@ -40,6 +53,14 @@ class ResultFormatter: | ||
| 40 | except (ValueError, TypeError): | 53 | except (ValueError, TypeError): |
| 41 | relevance_score = 0.0 | 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 | # Extract SKUs | 64 | # Extract SKUs |
| 44 | skus = [] | 65 | skus = [] |
| 45 | skus_data = source.get('skus', []) | 66 | skus_data = source.get('skus', []) |
| @@ -62,17 +83,33 @@ class ResultFormatter: | @@ -62,17 +83,33 @@ class ResultFormatter: | ||
| 62 | # Build SpuResult | 83 | # Build SpuResult |
| 63 | spu = SpuResult( | 84 | spu = SpuResult( |
| 64 | spu_id=str(source.get('spu_id', '')), | 85 | spu_id=str(source.get('spu_id', '')), |
| 65 | - title=source.get('title'), | 86 | + title=title, |
| 87 | + brief=brief, | ||
| 66 | handle=source.get('handle'), | 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 | tags=source.get('tags'), | 99 | tags=source.get('tags'), |
| 71 | price=source.get('min_price'), | 100 | price=source.get('min_price'), |
| 72 | compare_at_price=source.get('compare_at_price'), | 101 | compare_at_price=source.get('compare_at_price'), |
| 73 | currency="USD", # Default currency | 102 | currency="USD", # Default currency |
| 74 | image_url=source.get('image_url'), | 103 | image_url=source.get('image_url'), |
| 75 | in_stock=in_stock, | 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 | skus=skus, | 113 | skus=skus, |
| 77 | relevance_score=relevance_score | 114 | relevance_score=relevance_score |
| 78 | ) | 115 | ) |
api/routes/search.py
| @@ -94,7 +94,8 @@ async def search(request: SearchRequest, http_request: Request): | @@ -94,7 +94,8 @@ async def search(request: SearchRequest, http_request: Request): | ||
| 94 | context=context, | 94 | context=context, |
| 95 | sort_by=request.sort_by, | 95 | sort_by=request.sort_by, |
| 96 | sort_order=request.sort_order, | 96 | sort_order=request.sort_order, |
| 97 | - debug=request.debug | 97 | + debug=request.debug, |
| 98 | + language=request.language, | ||
| 98 | ) | 99 | ) |
| 99 | 100 | ||
| 100 | # Include performance summary in response | 101 | # Include performance summary in response |
docs/ES常用表达式.md
| @@ -7,3 +7,14 @@ GET /search_products/_search | @@ -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 | \ No newline at end of file | 21 | \ No newline at end of file |
search/query_config.py
| @@ -37,17 +37,32 @@ DOMAIN_FIELDS: Dict[str, List[str]] = { | @@ -37,17 +37,32 @@ DOMAIN_FIELDS: Dict[str, List[str]] = { | ||
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | # Source fields to return in search results | 39 | # Source fields to return in search results |
| 40 | +# 注意:为了在后端做多语言选择,_zh / _en 字段仍然需要从 ES 取出, | ||
| 41 | +# 但不会原样透出给前端,而是统一映射到 title / description / vendor 等字段。 | ||
| 40 | SOURCE_FIELDS = [ | 42 | SOURCE_FIELDS = [ |
| 43 | + # 基本标识 | ||
| 41 | "tenant_id", | 44 | "tenant_id", |
| 42 | "spu_id", | 45 | "spu_id", |
| 46 | + "create_time", | ||
| 47 | + "update_time", | ||
| 48 | + | ||
| 49 | + # 多语言文本字段(仅用于后端选择,不直接返回给前端) | ||
| 43 | "title_zh", | 50 | "title_zh", |
| 51 | + "title_en", | ||
| 44 | "brief_zh", | 52 | "brief_zh", |
| 53 | + "brief_en", | ||
| 45 | "description_zh", | 54 | "description_zh", |
| 55 | + "description_en", | ||
| 46 | "vendor_zh", | 56 | "vendor_zh", |
| 47 | - "tags", | ||
| 48 | - "image_url", | 57 | + "vendor_en", |
| 49 | "category_path_zh", | 58 | "category_path_zh", |
| 59 | + "category_path_en", | ||
| 50 | "category_name_zh", | 60 | "category_name_zh", |
| 61 | + "category_name_en", | ||
| 62 | + | ||
| 63 | + # 语言无关字段(直接返回给前端) | ||
| 64 | + "tags", | ||
| 65 | + "image_url", | ||
| 51 | "category_id", | 66 | "category_id", |
| 52 | "category_name", | 67 | "category_name", |
| 53 | "category_level", | 68 | "category_level", |
| @@ -60,11 +75,12 @@ SOURCE_FIELDS = [ | @@ -60,11 +75,12 @@ SOURCE_FIELDS = [ | ||
| 60 | "min_price", | 75 | "min_price", |
| 61 | "max_price", | 76 | "max_price", |
| 62 | "compare_at_price", | 77 | "compare_at_price", |
| 78 | + "sku_prices", | ||
| 79 | + "sku_weights", | ||
| 80 | + "sku_weight_units", | ||
| 63 | "total_inventory", | 81 | "total_inventory", |
| 64 | - "create_time", | ||
| 65 | - "update_time", | ||
| 66 | "skus", | 82 | "skus", |
| 67 | - "specifications" | 83 | + "specifications", |
| 68 | ] | 84 | ] |
| 69 | 85 | ||
| 70 | # Query processing settings | 86 | # Query processing settings |
search/searcher.py
| @@ -134,7 +134,8 @@ class Searcher: | @@ -134,7 +134,8 @@ class Searcher: | ||
| 134 | context: Optional[RequestContext] = None, | 134 | context: Optional[RequestContext] = None, |
| 135 | sort_by: Optional[str] = None, | 135 | sort_by: Optional[str] = None, |
| 136 | sort_order: Optional[str] = "desc", | 136 | sort_order: Optional[str] = "desc", |
| 137 | - debug: bool = False | 137 | + debug: bool = False, |
| 138 | + language: str = "zh", | ||
| 138 | ) -> SearchResult: | 139 | ) -> SearchResult: |
| 139 | """ | 140 | """ |
| 140 | Execute search query (外部友好格式). | 141 | Execute search query (外部友好格式). |
| @@ -373,7 +374,11 @@ class Searcher: | @@ -373,7 +374,11 @@ class Searcher: | ||
| 373 | max_score = es_response.get('hits', {}).get('max_score') or 0.0 | 374 | max_score = es_response.get('hits', {}).get('max_score') or 0.0 |
| 374 | 375 | ||
| 375 | # Format results using ResultFormatter | 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 | # Format facets | 383 | # Format facets |
| 379 | standardized_facets = None | 384 | standardized_facets = None |