From 577ec9724155f6d2cc94fa08e4e368a87245fcc0 Mon Sep 17 00:00:00 2001 From: tangwang Date: Wed, 26 Nov 2025 22:35:07 +0800 Subject: [PATCH] 返回给前端的字段、格式适配。主要包括字段配置、前端补充一个语言字段处理title_en title_zh等语言选择、分面信息的提取等 --- api/models.py | 37 +++++++++++++++++++++++++++++++++---- api/result_formatter.py | 47 ++++++++++++++++++++++++++++++++++++++++++----- api/routes/search.py | 3 ++- docs/ES常用表达式.md | 11 +++++++++++ search/query_config.py | 26 +++++++++++++++++++++----- search/searcher.py | 9 +++++++-- 6 files changed, 116 insertions(+), 17 deletions(-) diff --git a/api/models.py b/api/models.py index a5e565f..7ce52bc 100644 --- a/api/models.py +++ b/api/models.py @@ -69,6 +69,10 @@ class SearchRequest(BaseModel): query: str = Field(..., description="搜索查询字符串,支持布尔表达式(AND, OR, RANK, ANDNOT)") size: int = Field(10, ge=1, le=100, description="返回结果数量") from_: int = Field(0, ge=0, alias="from", description="分页偏移量") + language: Literal["zh", "en"] = Field( + "zh", + description="响应语言:'zh'(中文)或 'en'(英文),用于选择 title/description/vendor 等多语言字段" + ) # 过滤器 - 精确匹配和多值匹配 filters: Optional[Dict[str, Union[str, int, bool, List[Union[str, int]]]]] = Field( @@ -175,28 +179,53 @@ class FacetResult(BaseModel): class SkuResult(BaseModel): """SKU 结果""" sku_id: str = Field(..., description="SKU ID") - title: Optional[str] = Field(None, description="SKU标题") + # 与 ES nested skus 结构对齐 price: Optional[float] = Field(None, description="价格") compare_at_price: Optional[float] = Field(None, description="原价") - sku: Optional[str] = Field(None, description="SKU编码") + sku_code: Optional[str] = Field(None, description="SKU编码") stock: int = Field(0, description="库存数量") - options: Optional[Dict[str, Any]] = Field(None, description="选项(颜色、尺寸等)") + weight: Optional[float] = Field(None, description="重量") + weight_unit: Optional[str] = Field(None, description="重量单位") + option1_value: Optional[str] = Field(None, description="选项1取值(如颜色)") + option2_value: Optional[str] = Field(None, description="选项2取值(如尺码)") + option3_value: Optional[str] = Field(None, description="选项3取值") + image_src: Optional[str] = Field(None, description="SKU图片地址") class SpuResult(BaseModel): """SPU 搜索结果""" spu_id: str = Field(..., description="SPU ID") title: Optional[str] = Field(None, description="商品标题") + brief: Optional[str] = Field(None, description="商品短描述") handle: Optional[str] = Field(None, description="商品handle") description: Optional[str] = Field(None, description="商品描述") vendor: Optional[str] = Field(None, description="供应商/品牌") - category: Optional[str] = Field(None, description="类目") + category: Optional[str] = Field(None, description="类目(兼容字段,等同于category_name)") + category_path: Optional[str] = Field(None, description="类目路径(多级,用于面包屑)") + category_name: Optional[str] = Field(None, description="类目名称(展示用)") + category_id: Optional[str] = Field(None, description="类目ID") + category_level: Optional[int] = Field(None, description="类目层级") + category1_name: Optional[str] = Field(None, description="一级类目名称") + category2_name: Optional[str] = Field(None, description="二级类目名称") + category3_name: Optional[str] = Field(None, description="三级类目名称") tags: Optional[List[str]] = Field(None, description="标签列表") price: Optional[float] = Field(None, description="价格(min_price)") compare_at_price: Optional[float] = Field(None, description="原价") currency: str = Field("USD", description="货币单位") image_url: Optional[str] = Field(None, description="主图URL") in_stock: bool = Field(True, description="是否有库存") + # SKU 扁平化信息 + sku_prices: Optional[List[float]] = Field(None, description="所有SKU价格列表") + sku_weights: Optional[List[int]] = Field(None, description="所有SKU重量列表") + sku_weight_units: Optional[List[str]] = Field(None, description="所有SKU重量单位列表") + total_inventory: Optional[int] = Field(None, description="总库存") + option1_name: Optional[str] = Field(None, description="选项1名称(如颜色)") + option2_name: Optional[str] = Field(None, description="选项2名称(如尺码)") + option3_name: Optional[str] = Field(None, description="选项3名称") + specifications: Optional[List[Dict[str, Any]]] = Field( + None, + description="规格列表(与 ES specifications 字段对应)" + ) skus: List[SkuResult] = Field(default_factory=list, description="SKU列表") relevance_score: float = Field(..., ge=0.0, description="相关性分数(ES原始分数)") diff --git a/api/result_formatter.py b/api/result_formatter.py index 594d102..a2a0146 100644 --- a/api/result_formatter.py +++ b/api/result_formatter.py @@ -12,7 +12,8 @@ class ResultFormatter: @staticmethod def format_search_results( es_hits: List[Dict[str, Any]], - max_score: float = 1.0 + max_score: float = 1.0, + language: str = "zh" ) -> List[SpuResult]: """ Convert ES hits to SpuResult list. @@ -25,6 +26,18 @@ class ResultFormatter: List of SpuResult objects """ results = [] + lang = (language or "zh").lower() + if lang not in ("zh", "en"): + lang = "en" + + def pick_lang_field(src: Dict[str, Any], base: str) -> Optional[str]: + """从 *_zh / *_en 字段中按语言选择一个值,若目标语言缺失则回退到另一种。""" + zh_val = src.get(f"{base}_zh") + en_val = src.get(f"{base}_en") + if lang == "zh": + return zh_val or en_val + else: + return en_val or zh_val for hit in es_hits: source = hit.get('_source', {}) @@ -40,6 +53,14 @@ class ResultFormatter: except (ValueError, TypeError): relevance_score = 0.0 + # Multi-language fields + title = pick_lang_field(source, "title") + brief = pick_lang_field(source, "brief") + description = pick_lang_field(source, "description") + vendor = pick_lang_field(source, "vendor") + category_path = pick_lang_field(source, "category_path") + category_name = pick_lang_field(source, "category_name") + # Extract SKUs skus = [] skus_data = source.get('skus', []) @@ -62,17 +83,33 @@ class ResultFormatter: # Build SpuResult spu = SpuResult( spu_id=str(source.get('spu_id', '')), - title=source.get('title'), + title=title, + brief=brief, handle=source.get('handle'), - description=source.get('description'), - vendor=source.get('vendor'), - category=source.get('category'), + description=description, + vendor=vendor, + category=category_name, + category_path=category_path, + category_name=category_name, + category_id=source.get('category_id'), + category_level=source.get('category_level'), + category1_name=source.get('category1_name'), + category2_name=source.get('category2_name'), + category3_name=source.get('category3_name'), tags=source.get('tags'), price=source.get('min_price'), compare_at_price=source.get('compare_at_price'), currency="USD", # Default currency image_url=source.get('image_url'), in_stock=in_stock, + sku_prices=source.get('sku_prices'), + sku_weights=source.get('sku_weights'), + sku_weight_units=source.get('sku_weight_units'), + total_inventory=source.get('total_inventory'), + option1_name=source.get('option1_name'), + option2_name=source.get('option2_name'), + option3_name=source.get('option3_name'), + specifications=source.get('specifications'), skus=skus, relevance_score=relevance_score ) diff --git a/api/routes/search.py b/api/routes/search.py index cc53968..5cce887 100644 --- a/api/routes/search.py +++ b/api/routes/search.py @@ -94,7 +94,8 @@ async def search(request: SearchRequest, http_request: Request): context=context, sort_by=request.sort_by, sort_order=request.sort_order, - debug=request.debug + debug=request.debug, + language=request.language, ) # Include performance summary in response diff --git a/docs/ES常用表达式.md b/docs/ES常用表达式.md index 49ffbc3..2c9c0fe 100644 --- a/docs/ES常用表达式.md +++ b/docs/ES常用表达式.md @@ -7,3 +7,14 @@ GET /search_products/_search } } + +curl -u 'essa:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products/_search?pretty' -H 'Content-Type: application/json' -d '{ + "size": 5, + "query": { + "bool": { + "filter": [ + { "term": { "tenant_id": "162" } } + ] + } + } + }' \ No newline at end of file diff --git a/search/query_config.py b/search/query_config.py index e7d7747..1088e2d 100644 --- a/search/query_config.py +++ b/search/query_config.py @@ -37,17 +37,32 @@ DOMAIN_FIELDS: Dict[str, List[str]] = { } # Source fields to return in search results +# 注意:为了在后端做多语言选择,_zh / _en 字段仍然需要从 ES 取出, +# 但不会原样透出给前端,而是统一映射到 title / description / vendor 等字段。 SOURCE_FIELDS = [ + # 基本标识 "tenant_id", "spu_id", + "create_time", + "update_time", + + # 多语言文本字段(仅用于后端选择,不直接返回给前端) "title_zh", + "title_en", "brief_zh", + "brief_en", "description_zh", + "description_en", "vendor_zh", - "tags", - "image_url", + "vendor_en", "category_path_zh", + "category_path_en", "category_name_zh", + "category_name_en", + + # 语言无关字段(直接返回给前端) + "tags", + "image_url", "category_id", "category_name", "category_level", @@ -60,11 +75,12 @@ SOURCE_FIELDS = [ "min_price", "max_price", "compare_at_price", + "sku_prices", + "sku_weights", + "sku_weight_units", "total_inventory", - "create_time", - "update_time", "skus", - "specifications" + "specifications", ] # Query processing settings diff --git a/search/searcher.py b/search/searcher.py index 8b3ef47..238bcae 100644 --- a/search/searcher.py +++ b/search/searcher.py @@ -134,7 +134,8 @@ class Searcher: context: Optional[RequestContext] = None, sort_by: Optional[str] = None, sort_order: Optional[str] = "desc", - debug: bool = False + debug: bool = False, + language: str = "zh", ) -> SearchResult: """ Execute search query (外部友好格式). @@ -373,7 +374,11 @@ class Searcher: max_score = es_response.get('hits', {}).get('max_score') or 0.0 # Format results using ResultFormatter - formatted_results = ResultFormatter.format_search_results(es_hits, max_score) + formatted_results = ResultFormatter.format_search_results( + es_hits, + max_score, + language=language + ) # Format facets standardized_facets = None -- libgit2 0.21.2