Commit 768ad710662047243eb18877c9a43cc6ec54c60b
1 parent
74cca190
MySQL到ES字段映射说明-业务版.md
Showing
14 changed files
with
2402 additions
and
96 deletions
Show diff stats
README.md
| ... | ... | @@ -14,6 +14,33 @@ query anchor |
| 14 | 14 | |
| 15 | 15 | |
| 16 | 16 | |
| 17 | + | |
| 18 | +对外: | |
| 19 | +embedding服务: | |
| 20 | + curl -X POST http://120.76.41.98:6005/embed/text \ | |
| 21 | + -H "Content-Type: application/json" \ | |
| 22 | + -d '["衣服", "Bohemian Maxi Dress"]' | |
| 23 | + | |
| 24 | + | |
| 25 | +翻译服务: | |
| 26 | +# 方式1:直接运行 | |
| 27 | +python api/translator_app.py | |
| 28 | +# 方式2:使用 uvicorn | |
| 29 | +uvicorn api.translator_app:app --host 0.0.0.0 --port 6006 --reload | |
| 30 | + | |
| 31 | +curl -X POST http://localhost:6006/translate -H "Content-Type: application/json" -d '{ | |
| 32 | + "text": "商品名称", | |
| 33 | + "target_lang": "en", | |
| 34 | + "source_lang": "zh" | |
| 35 | + }' | |
| 36 | + | |
| 37 | +localhost替换为 | |
| 38 | +服务器内网地址: | |
| 39 | +10.0.163.168 | |
| 40 | +公网地址: | |
| 41 | +120.76.41.98 | |
| 42 | + | |
| 43 | + | |
| 17 | 44 | # 电商搜索引擎 SaaS |
| 18 | 45 | |
| 19 | 46 | 一个针对跨境独立站(店匠 Shoplazza 等)的多租户可配置搜索平台。README 作为项目导航入口,帮助你在不同阶段定位到更详细的文档。 | ... | ... |
| ... | ... | @@ -0,0 +1,283 @@ |
| 1 | + | |
| 2 | +""" | |
| 3 | + | |
| 4 | +# 方式1:直接运行 | |
| 5 | +python api/translator_app.py | |
| 6 | + | |
| 7 | +# 方式2:使用 uvicorn | |
| 8 | +uvicorn api.translator_app:app --host 0.0.0.0 --port 6006 --reload | |
| 9 | + | |
| 10 | + | |
| 11 | +使用说明: | |
| 12 | +Translation HTTP Service | |
| 13 | + | |
| 14 | +This service provides a RESTful API for text translation using DeepL API. | |
| 15 | +The service runs on port 6006 and provides a simple translation endpoint. | |
| 16 | + | |
| 17 | +API Endpoint: | |
| 18 | + POST /translate | |
| 19 | + | |
| 20 | +Request Body (JSON): | |
| 21 | + { | |
| 22 | + "text": "要翻译的文本", | |
| 23 | + "target_lang": "en", # Required: target language code (zh, en, ru, etc.) | |
| 24 | + "source_lang": "zh" # Optional: source language code (auto-detect if not provided) | |
| 25 | + } | |
| 26 | + | |
| 27 | +Response (JSON): | |
| 28 | + { | |
| 29 | + "text": "要翻译的文本", | |
| 30 | + "target_lang": "en", | |
| 31 | + "source_lang": "zh", | |
| 32 | + "translated_text": "Text to translate", | |
| 33 | + "status": "success" | |
| 34 | + } | |
| 35 | + | |
| 36 | +Usage Examples: | |
| 37 | + | |
| 38 | +1. Translate Chinese to English: | |
| 39 | + curl -X POST http://localhost:6006/translate \ | |
| 40 | + -H "Content-Type: application/json" \ | |
| 41 | + -d '{ | |
| 42 | + "text": "商品名称", | |
| 43 | + "target_lang": "en", | |
| 44 | + "source_lang": "zh" | |
| 45 | + }' | |
| 46 | + | |
| 47 | +2. Translate with auto-detection: | |
| 48 | + curl -X POST http://localhost:6006/translate \ | |
| 49 | + -H "Content-Type: application/json" \ | |
| 50 | + -d '{ | |
| 51 | + "text": "Product name", | |
| 52 | + "target_lang": "zh" | |
| 53 | + }' | |
| 54 | + | |
| 55 | +3. Translate Russian to English: | |
| 56 | + curl -X POST http://localhost:6006/translate \ | |
| 57 | + -H "Content-Type: application/json" \ | |
| 58 | + -d '{ | |
| 59 | + "text": "Название товара", | |
| 60 | + "target_lang": "en", | |
| 61 | + "source_lang": "ru" | |
| 62 | + }' | |
| 63 | + | |
| 64 | +Health Check: | |
| 65 | + GET /health | |
| 66 | + | |
| 67 | + curl http://localhost:6006/health | |
| 68 | + | |
| 69 | +Start the service: | |
| 70 | + python api/translator_app.py | |
| 71 | + # or | |
| 72 | + uvicorn api.translator_app:app --host 0.0.0.0 --port 6006 --reload | |
| 73 | +""" | |
| 74 | + | |
| 75 | +import os | |
| 76 | +import sys | |
| 77 | +import logging | |
| 78 | +import argparse | |
| 79 | +import uvicorn | |
| 80 | +from typing import Optional | |
| 81 | +from fastapi import FastAPI, HTTPException | |
| 82 | +from fastapi.responses import JSONResponse | |
| 83 | +from fastapi.middleware.cors import CORSMiddleware | |
| 84 | +from pydantic import BaseModel, Field | |
| 85 | + | |
| 86 | +# Add parent directory to path | |
| 87 | +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| 88 | + | |
| 89 | +from query.translator import Translator | |
| 90 | +from config.env_config import DEEPL_AUTH_KEY, REDIS_CONFIG | |
| 91 | + | |
| 92 | +# Configure logging | |
| 93 | +logging.basicConfig( | |
| 94 | + level=logging.INFO, | |
| 95 | + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' | |
| 96 | +) | |
| 97 | +logger = logging.getLogger(__name__) | |
| 98 | + | |
| 99 | +# Fixed translation prompt | |
| 100 | +TRANSLATION_PROMPT = "Translate the original text into an English product SKU name. Requirements: Ensure accurate and complete transmission of the original information, with concise, clear, authentic, and professional language." | |
| 101 | + | |
| 102 | +# Global translator instance | |
| 103 | +_translator: Optional[Translator] = None | |
| 104 | + | |
| 105 | + | |
| 106 | +def init_translator(): | |
| 107 | + """Initialize translator instance.""" | |
| 108 | + global _translator | |
| 109 | + if _translator is None: | |
| 110 | + logger.info("Initializing translator...") | |
| 111 | + _translator = Translator( | |
| 112 | + api_key=DEEPL_AUTH_KEY, | |
| 113 | + use_cache=True, | |
| 114 | + timeout=10 | |
| 115 | + ) | |
| 116 | + logger.info("Translator initialized") | |
| 117 | + return _translator | |
| 118 | + | |
| 119 | + | |
| 120 | +# Request/Response models | |
| 121 | +class TranslationRequest(BaseModel): | |
| 122 | + """Translation request model.""" | |
| 123 | + text: str = Field(..., description="Text to translate") | |
| 124 | + target_lang: str = Field(..., description="Target language code (zh, en, ru, etc.)") | |
| 125 | + source_lang: Optional[str] = Field(None, description="Source language code (optional, auto-detect if not provided)") | |
| 126 | + | |
| 127 | + class Config: | |
| 128 | + json_schema_extra = { | |
| 129 | + "example": { | |
| 130 | + "text": "商品名称", | |
| 131 | + "target_lang": "en", | |
| 132 | + "source_lang": "zh" | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + | |
| 137 | +class TranslationResponse(BaseModel): | |
| 138 | + """Translation response model.""" | |
| 139 | + text: str = Field(..., description="Original text") | |
| 140 | + target_lang: str = Field(..., description="Target language code") | |
| 141 | + source_lang: Optional[str] = Field(None, description="Source language code (detected or provided)") | |
| 142 | + translated_text: str = Field(..., description="Translated text") | |
| 143 | + status: str = Field(..., description="Translation status") | |
| 144 | + | |
| 145 | + | |
| 146 | +# Create FastAPI app | |
| 147 | +app = FastAPI( | |
| 148 | + title="Translation Service API", | |
| 149 | + description="RESTful API for text translation using DeepL", | |
| 150 | + version="1.0.0", | |
| 151 | + docs_url="/docs", | |
| 152 | + redoc_url="/redoc" | |
| 153 | +) | |
| 154 | + | |
| 155 | +# Add CORS middleware | |
| 156 | +app.add_middleware( | |
| 157 | + CORSMiddleware, | |
| 158 | + allow_origins=["*"], | |
| 159 | + allow_credentials=True, | |
| 160 | + allow_methods=["*"], | |
| 161 | + allow_headers=["*"], | |
| 162 | +) | |
| 163 | + | |
| 164 | + | |
| 165 | +@app.on_event("startup") | |
| 166 | +async def startup_event(): | |
| 167 | + """Initialize translator on startup.""" | |
| 168 | + logger.info("Starting Translation Service API on port 6006") | |
| 169 | + try: | |
| 170 | + init_translator() | |
| 171 | + logger.info("Translation service ready") | |
| 172 | + except Exception as e: | |
| 173 | + logger.error(f"Failed to initialize translator: {e}", exc_info=True) | |
| 174 | + logger.warning("Service will start but translation may not work correctly") | |
| 175 | + | |
| 176 | + | |
| 177 | +@app.get("/health") | |
| 178 | +async def health_check(): | |
| 179 | + """Health check endpoint.""" | |
| 180 | + try: | |
| 181 | + translator = init_translator() | |
| 182 | + return { | |
| 183 | + "status": "healthy", | |
| 184 | + "service": "translation", | |
| 185 | + "translator_initialized": translator is not None, | |
| 186 | + "cache_enabled": translator.use_cache if translator else False | |
| 187 | + } | |
| 188 | + except Exception as e: | |
| 189 | + logger.error(f"Health check failed: {e}") | |
| 190 | + return JSONResponse( | |
| 191 | + status_code=503, | |
| 192 | + content={ | |
| 193 | + "status": "unhealthy", | |
| 194 | + "error": str(e) | |
| 195 | + } | |
| 196 | + ) | |
| 197 | + | |
| 198 | + | |
| 199 | +@app.post("/translate", response_model=TranslationResponse) | |
| 200 | +async def translate(request: TranslationRequest): | |
| 201 | + """ | |
| 202 | + Translate text to target language. | |
| 203 | + | |
| 204 | + Uses a fixed prompt optimized for product SKU name translation. | |
| 205 | + The translation is cached in Redis for performance. | |
| 206 | + """ | |
| 207 | + if not request.text or not request.text.strip(): | |
| 208 | + raise HTTPException( | |
| 209 | + status_code=400, | |
| 210 | + detail="Text cannot be empty" | |
| 211 | + ) | |
| 212 | + | |
| 213 | + if not request.target_lang: | |
| 214 | + raise HTTPException( | |
| 215 | + status_code=400, | |
| 216 | + detail="target_lang is required" | |
| 217 | + ) | |
| 218 | + | |
| 219 | + try: | |
| 220 | + translator = init_translator() | |
| 221 | + | |
| 222 | + # Translate using the fixed prompt | |
| 223 | + translated_text = translator.translate( | |
| 224 | + text=request.text, | |
| 225 | + target_lang=request.target_lang, | |
| 226 | + source_lang=request.source_lang, | |
| 227 | + prompt=TRANSLATION_PROMPT | |
| 228 | + ) | |
| 229 | + | |
| 230 | + if translated_text is None: | |
| 231 | + raise HTTPException( | |
| 232 | + status_code=500, | |
| 233 | + detail="Translation failed" | |
| 234 | + ) | |
| 235 | + | |
| 236 | + return TranslationResponse( | |
| 237 | + text=request.text, | |
| 238 | + target_lang=request.target_lang, | |
| 239 | + source_lang=request.source_lang, | |
| 240 | + translated_text=translated_text, | |
| 241 | + status="success" | |
| 242 | + ) | |
| 243 | + | |
| 244 | + except HTTPException: | |
| 245 | + raise | |
| 246 | + except Exception as e: | |
| 247 | + logger.error(f"Translation error: {e}", exc_info=True) | |
| 248 | + raise HTTPException( | |
| 249 | + status_code=500, | |
| 250 | + detail=f"Translation error: {str(e)}" | |
| 251 | + ) | |
| 252 | + | |
| 253 | + | |
| 254 | +@app.get("/") | |
| 255 | +async def root(): | |
| 256 | + """Root endpoint with API information.""" | |
| 257 | + return { | |
| 258 | + "service": "Translation Service API", | |
| 259 | + "version": "1.0.0", | |
| 260 | + "status": "running", | |
| 261 | + "endpoints": { | |
| 262 | + "translate": "POST /translate", | |
| 263 | + "health": "GET /health", | |
| 264 | + "docs": "GET /docs" | |
| 265 | + } | |
| 266 | + } | |
| 267 | + | |
| 268 | + | |
| 269 | +if __name__ == "__main__": | |
| 270 | + parser = argparse.ArgumentParser(description='Start translation API service') | |
| 271 | + parser.add_argument('--host', default='0.0.0.0', help='Host to bind to') | |
| 272 | + parser.add_argument('--port', type=int, default=6006, help='Port to bind to') | |
| 273 | + parser.add_argument('--reload', action='store_true', help='Enable auto-reload') | |
| 274 | + args = parser.parse_args() | |
| 275 | + | |
| 276 | + # Run server | |
| 277 | + uvicorn.run( | |
| 278 | + "api.translator_app:app", | |
| 279 | + host=args.host, | |
| 280 | + port=args.port, | |
| 281 | + reload=args.reload | |
| 282 | + ) | |
| 283 | + | ... | ... |
docs/CNCLIP_SERVICE.md renamed to docs/CNCLIP_SERVICE说明文档.md
| 1 | + | |
| 2 | +# TODO | |
| 3 | + | |
| 4 | +现在,跟自己 cn_clip 预估的结果,有差别: | |
| 5 | +这个比较接近: 可能是预处理逻辑有些不一样。 | |
| 6 | +https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg | |
| 7 | +normlize后的结果: | |
| 8 | +0.046295166015625,0.012847900390625,-0.0299530029296875,-0.01629638671875,0.01708984375,0.00487518310546875,0.01284027099609375,0.01348876953125,0.04617632180452347, 0.012860896065831184, -0.030133124440908432, -0.0162516962736845, | |
| 9 | +0.04617632180452347, 0.012860896065831184, -0.030133124440908432, -0.0162516962736845, 0.01708567887544632, 0.005110889207571745 | |
| 10 | + | |
| 11 | +以下两个,差别非常大,感觉不是一个模型: | |
| 12 | +https://aisearch.cdn.bcebos.com/fileManager/GtB5doGAr1skTx38P7fb7Q/182.jpg?authorization=bce-auth-v1%2F7e22d8caf5af46cc9310f1e3021709f3%2F2025-12-30T04%3A45%3A38Z%2F86400%2Fhost%2Ffe222039926cb7ff593021af40268c782b8892598114e24773d0c1bfc976a8df | |
| 13 | +https://oss.essa.cn/2e353867-7496-4d4e-a7c8-0af50f49f6eb.jpg?x-oss-process=image/resize,m_lfit,w_2048,h_2048 | |
| 14 | + | |
| 15 | +curl -X POST "http://120.76.41.98:5000/embedding/generate_image_embeddings" -H "Content-Type: application/json" -d '[ | |
| 16 | + { | |
| 17 | + "id": "test_1", | |
| 18 | + "pic_url": "https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg" | |
| 19 | + } | |
| 20 | + ]' | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 1 | 24 | # CN-CLIP 编码服务 |
| 2 | 25 | |
| 3 | 26 | ## 模块说明 | ... | ... |
| ... | ... | @@ -0,0 +1,570 @@ |
| 1 | +# MySQL 到 Elasticsearch 字段映射说明(业务版) | |
| 2 | + | |
| 3 | +## 1. 概述 | |
| 4 | + | |
| 5 | +本文档说明 Elasticsearch 中的商品文档(Document)的各个字段是如何从 MySQL 数据库中的相关表获取和转换的。 | |
| 6 | + | |
| 7 | +### 1.1 数据源表 | |
| 8 | + | |
| 9 | +- **SPU 表** (`shoplazza_product_spu`): 商品基本信息 | |
| 10 | +- **SKU 表** (`shoplazza_product_sku`): 商品变体信息(多款式) | |
| 11 | +- **Option 表** (`shoplazza_product_option`): 选项名称定义(如:颜色、尺寸) | |
| 12 | +- **类目表** (`product_category`): 类目信息(通过 SPU 表的 category_id 关联) | |
| 13 | + | |
| 14 | +### 1.2 数据关系 | |
| 15 | + | |
| 16 | +``` | |
| 17 | +SPU (1) ──→ (N) SKU # 一个商品有多个变体 | |
| 18 | +SPU (1) ──→ (N) Option # 一个商品有多个选项维度 | |
| 19 | +SPU.category_id ──→ 类目表 # 商品关联类目 | |
| 20 | +SKU.option1/2/3 ──→ Option # SKU 的选项值对应 Option 的 position | |
| 21 | +``` | |
| 22 | + | |
| 23 | +--- | |
| 24 | + | |
| 25 | +## 2. 基础字段映射 | |
| 26 | + | |
| 27 | +### 2.1 标识字段 | |
| 28 | + | |
| 29 | +| ES 字段 | 数据来源表 | 表中字段 | 转换说明 | | |
| 30 | +|---------|-----------|----------|----------| | |
| 31 | +| `tenant_id` | SPU 表 | `tenant_id` | 直接使用,转为字符串 | | |
| 32 | +| `spu_id` | SPU 表 | `id` | 直接使用,转为字符串 | | |
| 33 | + | |
| 34 | +### 2.2 文本字段(多语言) | |
| 35 | + | |
| 36 | +文本字段支持中英文双语,根据租户配置自动翻译。 | |
| 37 | + | |
| 38 | +| ES 字段 | 数据来源表 | 表中字段 | 转换说明 | | |
| 39 | +|---------|-----------|----------|----------| | |
| 40 | +| `title_zh` | SPU 表 | `title` | 如果主语言是中文,直接使用;否则翻译为中文 | | |
| 41 | +| `title_en` | SPU 表 | `title` | 如果主语言是英文,直接使用;否则翻译为英文 | | |
| 42 | +| `brief_zh` | SPU 表 | `brief` | 同上 | | |
| 43 | +| `brief_en` | SPU 表 | `brief` | 同上 | | |
| 44 | +| `description_zh` | SPU 表 | `description` | 同上 | | |
| 45 | +| `description_en` | SPU 表 | `description` | 同上 | | |
| 46 | +| `vendor_zh` | SPU 表 | `vendor` | 同上 | | |
| 47 | +| `vendor_en` | SPU 表 | `vendor` | 同上 | | |
| 48 | + | |
| 49 | +**翻译规则:** | |
| 50 | +- 根据租户配置的 `primary_language` 确定主语言 | |
| 51 | +- 如果配置了 `translate_to_en=true`,且主语言不是英文,则翻译为英文 | |
| 52 | +- 如果配置了 `translate_to_zh=true`,且主语言不是中文,则翻译为中文 | |
| 53 | + | |
| 54 | +### 2.3 标签字段 | |
| 55 | + | |
| 56 | +| ES 字段 | 数据来源表 | 表中字段 | 转换说明 | | |
| 57 | +|---------|-----------|----------|----------| | |
| 58 | +| `tags` | SPU 表 | `tags` | 逗号分隔的字符串 → 数组<br/>示例:`"标签1,标签2"` → `["标签1", "标签2"]` | | |
| 59 | + | |
| 60 | +### 2.4 图片字段 | |
| 61 | + | |
| 62 | +| ES 字段 | 数据来源表 | 表中字段 | 转换说明 | | |
| 63 | +|---------|-----------|----------|----------| | |
| 64 | +| `image_url` | SPU 表 | `image_src` | 直接使用,如果 URL 不以 `http` 开头则添加 `//` 前缀 | | |
| 65 | + | |
| 66 | +### 2.5 销量字段 | |
| 67 | + | |
| 68 | +| ES 字段 | 数据来源表 | 表中字段 | 转换说明 | | |
| 69 | +|---------|-----------|----------|----------| | |
| 70 | +| `sales` | SPU 表 | `fake_sales` | 转为整数,如果为空则为 0 | | |
| 71 | + | |
| 72 | +### 2.6 时间字段 | |
| 73 | + | |
| 74 | +| ES 字段 | 数据来源表 | 表中字段 | 转换说明 | | |
| 75 | +|---------|-----------|----------|----------| | |
| 76 | +| `create_time` | SPU 表 | `create_time` | 转为 ISO 格式字符串(如:`2024-01-01T00:00:00`) | | |
| 77 | +| `update_time` | SPU 表 | `update_time` | 转为 ISO 格式字符串 | | |
| 78 | + | |
| 79 | +--- | |
| 80 | + | |
| 81 | +## 3. 类别(Category)字段映射 | |
| 82 | + | |
| 83 | +### 3.1 类别数据来源 | |
| 84 | + | |
| 85 | +类别信息主要来自 SPU 表的以下字段: | |
| 86 | +- `category_path`: 类目路径,逗号分隔的类目ID列表(如:`"1,2,3"`) | |
| 87 | +- `category`: 类目名称(备用字段) | |
| 88 | +- `category_id`: 当前类目ID | |
| 89 | +- `category_level`: 类目层级 | |
| 90 | + | |
| 91 | +### 3.2 类别映射流程 | |
| 92 | + | |
| 93 | +**步骤 1:解析 category_path** | |
| 94 | + | |
| 95 | +从 SPU 表的 `category_path` 字段解析出类目ID列表: | |
| 96 | + | |
| 97 | +``` | |
| 98 | +category_path = "1,2,3" | |
| 99 | +→ category_ids = ["1", "2", "3"] | |
| 100 | +``` | |
| 101 | + | |
| 102 | +**步骤 2:ID 转名称** | |
| 103 | + | |
| 104 | +通过预加载的类目映射表(从 SPU 表查询 `category_id` 和 `category` 字段构建),将ID转换为名称: | |
| 105 | + | |
| 106 | +``` | |
| 107 | +映射表:{"1": "电子产品", "2": "手机", "3": "iPhone"} | |
| 108 | +category_ids = ["1", "2", "3"] | |
| 109 | +→ category_names = ["电子产品", "手机", "iPhone"] | |
| 110 | +``` | |
| 111 | + | |
| 112 | +**步骤 3:构建 ES 字段** | |
| 113 | + | |
| 114 | +| ES 字段 | 数据来源 | 转换说明 | | |
| 115 | +|---------|----------|----------| | |
| 116 | +| `category_path_zh` | 类目名称列表 | 用 `/` 连接:`"电子产品/手机/iPhone"` | | |
| 117 | +| `category1_name` | 类目名称列表[0] | 一级类目:`"电子产品"` | | |
| 118 | +| `category2_name` | 类目名称列表[1] | 二级类目:`"手机"` | | |
| 119 | +| `category3_name` | 类目名称列表[2] | 三级类目:`"iPhone"` | | |
| 120 | +| `category_id` | SPU 表 | `category_id` 转为字符串 | | |
| 121 | +| `category_level` | SPU 表 | `category_level` 转为整数 | | |
| 122 | + | |
| 123 | +**备用处理:** | |
| 124 | + | |
| 125 | +如果 `category_path` 为空,使用 `category` 字段作为备选: | |
| 126 | +- 如果 `category` 包含 `/`,按 `/` 分割为多级类目 | |
| 127 | +- 否则,直接作为 `category1_name` | |
| 128 | + | |
| 129 | +**数据质量检查:** | |
| 130 | + | |
| 131 | +如果 `category_path` 中的类目ID在映射表中不存在,则不写入任何类目字段(视为没有类目)。 | |
| 132 | + | |
| 133 | +--- | |
| 134 | + | |
| 135 | +## 4. 多款式(SKU/Options)字段映射 | |
| 136 | + | |
| 137 | +### 4.1 SKU 嵌套结构 | |
| 138 | + | |
| 139 | +一个 SPU 可以有多个 SKU,每个 SKU 代表一个商品变体(如:红色-L码、蓝色-M码)。 | |
| 140 | + | |
| 141 | +**ES 中的 `skus` 字段结构:** | |
| 142 | + | |
| 143 | +```json | |
| 144 | +{ | |
| 145 | + "skus": [ | |
| 146 | + { | |
| 147 | + "sku_id": "123", | |
| 148 | + "price": 99.99, | |
| 149 | + "compare_at_price": 129.99, | |
| 150 | + "sku_code": "SKU001", | |
| 151 | + "stock": 100, | |
| 152 | + "weight": 0.5, | |
| 153 | + "weight_unit": "kg", | |
| 154 | + "option1_value": "红色", | |
| 155 | + "option2_value": "L", | |
| 156 | + "option3_value": "棉", | |
| 157 | + "image_src": "https://..." | |
| 158 | + } | |
| 159 | + ] | |
| 160 | +} | |
| 161 | +``` | |
| 162 | + | |
| 163 | +### 4.2 SKU 字段映射 | |
| 164 | + | |
| 165 | +| ES 字段 | 数据来源表 | 表中字段 | 转换说明 | | |
| 166 | +|---------|-----------|----------|----------| | |
| 167 | +| `skus[].sku_id` | SKU 表 | `id` | 转为字符串 | | |
| 168 | +| `skus[].price` | SKU 表 | `price` | 转为浮点数 | | |
| 169 | +| `skus[].compare_at_price` | SKU 表 | `compare_at_price` | 转为浮点数 | | |
| 170 | +| `skus[].sku_code` | SKU 表 | `sku` | 转为字符串 | | |
| 171 | +| `skus[].stock` | SKU 表 | `inventory_quantity` | 转为整数 | | |
| 172 | +| `skus[].weight` | SKU 表 | `weight` | 转为浮点数 | | |
| 173 | +| `skus[].weight_unit` | SKU 表 | `weight_unit` | 转为字符串 | | |
| 174 | +| `skus[].image_src` | SKU 表 | `image_src` | 转为字符串 | | |
| 175 | +| `skus[].option1_value` | SKU 表 | `option1` | 转为字符串 | | |
| 176 | +| `skus[].option2_value` | SKU 表 | `option2` | 转为字符串 | | |
| 177 | +| `skus[].option3_value` | SKU 表 | `option3` | 转为字符串 | | |
| 178 | + | |
| 179 | +### 4.3 选项名称字段 | |
| 180 | + | |
| 181 | +选项名称来自 Option 表,按 `position` 字段排序(1、2、3 对应 option1、option2、option3)。 | |
| 182 | + | |
| 183 | +| ES 字段 | 数据来源表 | 表中字段 | 转换说明 | | |
| 184 | +|---------|-----------|----------|----------| | |
| 185 | +| `option1_name` | Option 表 | `name` (where position=1) | 第一个选项的名称(如:"颜色") | | |
| 186 | +| `option2_name` | Option 表 | `name` (where position=2) | 第二个选项的名称(如:"尺寸") | | |
| 187 | +| `option3_name` | Option 表 | `name` (where position=3) | 第三个选项的名称(如:"材质") | | |
| 188 | + | |
| 189 | +**查询逻辑:** | |
| 190 | + | |
| 191 | +```sql | |
| 192 | +SELECT position, name | |
| 193 | +FROM shoplazza_product_option | |
| 194 | +WHERE spu_id = ? AND deleted = 0 | |
| 195 | +ORDER BY position | |
| 196 | +``` | |
| 197 | + | |
| 198 | +### 4.4 选项值字段 | |
| 199 | + | |
| 200 | +选项值来自所有 SKU 的 `option1`、`option2`、`option3` 字段,去重后形成列表。 | |
| 201 | + | |
| 202 | +| ES 字段 | 数据来源表 | 表中字段 | 转换说明 | | |
| 203 | +|---------|-----------|----------|----------| | |
| 204 | +| `option1_values` | SKU 表 | `option1` | 收集所有 SKU 的 option1 值,去重<br/>示例:`["红色", "蓝色", "绿色"]` | | |
| 205 | +| `option2_values` | SKU 表 | `option2` | 收集所有 SKU 的 option2 值,去重<br/>示例:`["S", "M", "L"]` | | |
| 206 | +| `option3_values` | SKU 表 | `option3` | 收集所有 SKU 的 option3 值,去重<br/>示例:`["棉", "涤纶"]` | | |
| 207 | + | |
| 208 | +**转换逻辑:** | |
| 209 | + | |
| 210 | +``` | |
| 211 | +遍历所有 SKU: | |
| 212 | + - 收集 option1 值 → option1_values 列表 | |
| 213 | + - 收集 option2 值 → option2_values 列表 | |
| 214 | + - 收集 option3 值 → option3_values 列表 | |
| 215 | +去重后写入 ES | |
| 216 | +``` | |
| 217 | + | |
| 218 | +**注意:** 只有配置在 `searchable_option_dimensions` 中的选项才会被索引。 | |
| 219 | + | |
| 220 | +### 4.5 规格(Specifications)字段 | |
| 221 | + | |
| 222 | +规格字段用于支持按规格过滤和分面搜索,将 SKU 的选项值结构化存储。 | |
| 223 | + | |
| 224 | +**ES 中的 `specifications` 字段结构:** | |
| 225 | + | |
| 226 | +```json | |
| 227 | +{ | |
| 228 | + "specifications": [ | |
| 229 | + {"sku_id": "123", "name": "颜色", "value": "红色"}, | |
| 230 | + {"sku_id": "123", "name": "尺寸", "value": "L"}, | |
| 231 | + {"sku_id": "124", "name": "颜色", "value": "蓝色"}, | |
| 232 | + {"sku_id": "124", "name": "尺寸", "value": "M"} | |
| 233 | + ] | |
| 234 | +} | |
| 235 | +``` | |
| 236 | + | |
| 237 | +**构建逻辑:** | |
| 238 | + | |
| 239 | +1. 从 Option 表获取选项名称映射: | |
| 240 | + ``` | |
| 241 | + position=1 → name="颜色" | |
| 242 | + position=2 → name="尺寸" | |
| 243 | + position=3 → name="材质" | |
| 244 | + ``` | |
| 245 | + | |
| 246 | +2. 遍历所有 SKU,为每个 SKU 的每个选项值构建规格记录: | |
| 247 | + ``` | |
| 248 | + SKU.id=123, option1="红色", option2="L" | |
| 249 | + → {"sku_id": "123", "name": "颜色", "value": "红色"} | |
| 250 | + → {"sku_id": "123", "name": "尺寸", "value": "L"} | |
| 251 | + ``` | |
| 252 | + | |
| 253 | +| ES 字段 | 数据来源 | 转换说明 | | |
| 254 | +|---------|----------|----------| | |
| 255 | +| `specifications[].sku_id` | SKU 表 | `id` 转为字符串 | | |
| 256 | +| `specifications[].name` | Option 表 | `name`(根据 position 匹配) | | |
| 257 | +| `specifications[].value` | SKU 表 | `option1/2/3` 转为字符串 | | |
| 258 | + | |
| 259 | +### 4.6 价格聚合字段 | |
| 260 | + | |
| 261 | +从所有 SKU 的价格中计算聚合值。 | |
| 262 | + | |
| 263 | +| ES 字段 | 数据来源 | 转换说明 | | |
| 264 | +|---------|----------|----------| | |
| 265 | +| `min_price` | SKU 表 | 所有 SKU 的 `price` 最小值 | | |
| 266 | +| `max_price` | SKU 表 | 所有 SKU 的 `price` 最大值 | | |
| 267 | +| `compare_at_price` | SKU 表 | 所有 SKU 的 `compare_at_price` 最大值 | | |
| 268 | +| `sku_prices` | SKU 表 | 所有 SKU 的 `price` 数组<br/>示例:`[99.99, 99.99, 129.99]` | | |
| 269 | + | |
| 270 | +**计算逻辑:** | |
| 271 | + | |
| 272 | +``` | |
| 273 | +遍历所有 SKU: | |
| 274 | + - 收集 price → prices 列表 | |
| 275 | + - 收集 compare_at_price → compare_prices 列表 | |
| 276 | + | |
| 277 | +min_price = min(prices) | |
| 278 | +max_price = max(prices) | |
| 279 | +compare_at_price = max(compare_prices) | |
| 280 | +sku_prices = prices 列表 | |
| 281 | +``` | |
| 282 | + | |
| 283 | +### 4.7 库存和重量聚合字段 | |
| 284 | + | |
| 285 | +| ES 字段 | 数据来源 | 转换说明 | | |
| 286 | +|---------|----------|----------| | |
| 287 | +| `total_inventory` | SKU 表 | 所有 SKU 的 `inventory_quantity` 求和 | | |
| 288 | +| `sku_weights` | SKU 表 | 所有 SKU 的 `weight` 数组<br/>示例:`[500, 500, 600]` | | |
| 289 | +| `sku_weight_units` | SKU 表 | 所有 SKU 的 `weight_unit` 去重后的列表<br/>示例:`["kg"]` | | |
| 290 | + | |
| 291 | +**计算逻辑:** | |
| 292 | + | |
| 293 | +``` | |
| 294 | +遍历所有 SKU: | |
| 295 | + - 累加 inventory_quantity → total_inventory | |
| 296 | + - 收集 weight → sku_weights 列表 | |
| 297 | + - 收集 weight_unit → sku_weight_units 列表 | |
| 298 | + | |
| 299 | +total_inventory = 所有 SKU 库存之和 | |
| 300 | +sku_weights = 所有 SKU 重量数组 | |
| 301 | +sku_weight_units = 去重后的重量单位列表 | |
| 302 | +``` | |
| 303 | + | |
| 304 | +--- | |
| 305 | + | |
| 306 | +## 5. 向量字段映射 | |
| 307 | + | |
| 308 | +### 5.1 标题向量(title_embedding) | |
| 309 | + | |
| 310 | +| ES 字段 | 数据来源 | 转换说明 | | |
| 311 | +|---------|----------|----------| | |
| 312 | +| `title_embedding` | SPU 表 `title` | 使用文本编码器(BGE)将标题转换为 1024 维向量<br/>优先使用 `title_en`,如果没有则使用 `title_zh` | | |
| 313 | + | |
| 314 | +**生成逻辑:** | |
| 315 | + | |
| 316 | +``` | |
| 317 | +如果启用向量搜索: | |
| 318 | + 文本 = title_en 或 title_zh | |
| 319 | + 向量 = 文本编码器.encode(文本) | |
| 320 | + title_embedding = 向量(1024 维浮点数组) | |
| 321 | +``` | |
| 322 | + | |
| 323 | +--- | |
| 324 | + | |
| 325 | +## 6. 完整字段映射表 | |
| 326 | + | |
| 327 | +### 6.1 字段来源汇总 | |
| 328 | + | |
| 329 | +| ES 字段 | 数据来源表 | 表中字段 | 说明 | | |
| 330 | +|---------|-----------|----------|------| | |
| 331 | +| **基础字段** | | |
| 332 | +| `tenant_id` | SPU | `tenant_id` | 租户ID | | |
| 333 | +| `spu_id` | SPU | `id` | 商品ID | | |
| 334 | +| `title_zh/en` | SPU | `title` | 标题(多语言) | | |
| 335 | +| `brief_zh/en` | SPU | `brief` | 简介(多语言) | | |
| 336 | +| `description_zh/en` | SPU | `description` | 描述(多语言) | | |
| 337 | +| `vendor_zh/en` | SPU | `vendor` | 品牌(多语言) | | |
| 338 | +| `tags` | SPU | `tags` | 标签数组 | | |
| 339 | +| `image_url` | SPU | `image_src` | 主图URL | | |
| 340 | +| `sales` | SPU | `fake_sales` | 销量 | | |
| 341 | +| `create_time` | SPU | `create_time` | 创建时间 | | |
| 342 | +| `update_time` | SPU | `update_time` | 更新时间 | | |
| 343 | +| **类别字段** | | |
| 344 | +| `category_path_zh` | SPU + 类目映射 | `category_path` → 类目名称 | 类目路径 | | |
| 345 | +| `category1_name` | SPU + 类目映射 | `category_path` → 类目名称[0] | 一级类目 | | |
| 346 | +| `category2_name` | SPU + 类目映射 | `category_path` → 类目名称[1] | 二级类目 | | |
| 347 | +| `category3_name` | SPU + 类目映射 | `category_path` → 类目名称[2] | 三级类目 | | |
| 348 | +| `category_id` | SPU | `category_id` | 类目ID | | |
| 349 | +| `category_level` | SPU | `category_level` | 类目层级 | | |
| 350 | +| **SKU 嵌套字段** | | |
| 351 | +| `skus[]` | SKU | 多个字段 | SKU 数组(见 4.2 节) | | |
| 352 | +| **选项字段** | | |
| 353 | +| `option1_name` | Option | `name` (position=1) | 选项1名称 | | |
| 354 | +| `option2_name` | Option | `name` (position=2) | 选项2名称 | | |
| 355 | +| `option3_name` | Option | `name` (position=3) | 选项3名称 | | |
| 356 | +| `option1_values` | SKU | `option1` | 选项1值列表 | | |
| 357 | +| `option2_values` | SKU | `option2` | 选项2值列表 | | |
| 358 | +| `option3_values` | SKU | `option3` | 选项3值列表 | | |
| 359 | +| **规格字段** | | |
| 360 | +| `specifications[]` | SKU + Option | `option1/2/3` + `name` | 规格数组 | | |
| 361 | +| **聚合字段** | | |
| 362 | +| `min_price` | SKU | `price` | 最低价格 | | |
| 363 | +| `max_price` | SKU | `price` | 最高价格 | | |
| 364 | +| `compare_at_price` | SKU | `compare_at_price` | 最高原价 | | |
| 365 | +| `sku_prices` | SKU | `price` | 所有价格数组 | | |
| 366 | +| `total_inventory` | SKU | `inventory_quantity` | 总库存 | | |
| 367 | +| `sku_weights` | SKU | `weight` | 所有重量数组 | | |
| 368 | +| `sku_weight_units` | SKU | `weight_unit` | 重量单位列表 | | |
| 369 | +| **向量字段** | | |
| 370 | +| `title_embedding` | SPU | `title` | 标题向量(1024维) | | |
| 371 | + | |
| 372 | +--- | |
| 373 | + | |
| 374 | +## 7. 数据查询示例 | |
| 375 | + | |
| 376 | +### 7.1 查询 SPU 数据 | |
| 377 | + | |
| 378 | +```sql | |
| 379 | +SELECT | |
| 380 | + id, tenant_id, title, brief, description, vendor, | |
| 381 | + category, category_id, category_path, category_level, | |
| 382 | + tags, image_src, fake_sales, create_time, update_time | |
| 383 | +FROM shoplazza_product_spu | |
| 384 | +WHERE tenant_id = ? AND id = ? AND deleted = 0 | |
| 385 | +``` | |
| 386 | + | |
| 387 | +### 7.2 查询 SKU 数据 | |
| 388 | + | |
| 389 | +```sql | |
| 390 | +SELECT | |
| 391 | + id, spu_id, price, compare_at_price, sku, | |
| 392 | + inventory_quantity, weight, weight_unit, | |
| 393 | + option1, option2, option3, image_src | |
| 394 | +FROM shoplazza_product_sku | |
| 395 | +WHERE tenant_id = ? AND spu_id = ? AND deleted = 0 | |
| 396 | +``` | |
| 397 | + | |
| 398 | +### 7.3 查询 Option 数据 | |
| 399 | + | |
| 400 | +```sql | |
| 401 | +SELECT | |
| 402 | + position, name | |
| 403 | +FROM shoplazza_product_option | |
| 404 | +WHERE tenant_id = ? AND spu_id = ? AND deleted = 0 | |
| 405 | +ORDER BY position | |
| 406 | +``` | |
| 407 | + | |
| 408 | +### 7.4 查询类目映射 | |
| 409 | + | |
| 410 | +```sql | |
| 411 | +SELECT DISTINCT | |
| 412 | + category_id, category | |
| 413 | +FROM shoplazza_product_spu | |
| 414 | +WHERE deleted = 0 AND category_id IS NOT NULL | |
| 415 | +``` | |
| 416 | + | |
| 417 | +--- | |
| 418 | + | |
| 419 | +## 8. ES 文档示例 | |
| 420 | + | |
| 421 | +```json | |
| 422 | +{ | |
| 423 | + "tenant_id": "1", | |
| 424 | + "spu_id": "12345", | |
| 425 | + "title_zh": "iPhone 15 Pro Max", | |
| 426 | + "title_en": "iPhone 15 Pro Max", | |
| 427 | + "brief_zh": "最新款 iPhone", | |
| 428 | + "brief_en": "Latest iPhone", | |
| 429 | + "description_zh": "详细描述...", | |
| 430 | + "description_en": "Detailed description...", | |
| 431 | + "vendor_zh": "Apple", | |
| 432 | + "vendor_en": "Apple", | |
| 433 | + "tags": ["手机", "智能手机", "Apple"], | |
| 434 | + "category_path_zh": "电子产品/手机/iPhone", | |
| 435 | + "category1_name": "电子产品", | |
| 436 | + "category2_name": "手机", | |
| 437 | + "category3_name": "iPhone", | |
| 438 | + "category_id": "3", | |
| 439 | + "category_level": 3, | |
| 440 | + "option1_name": "颜色", | |
| 441 | + "option2_name": "存储容量", | |
| 442 | + "option1_values": ["深空黑色", "原色钛金属", "白色钛金属"], | |
| 443 | + "option2_values": ["256GB", "512GB", "1TB"], | |
| 444 | + "image_url": "https://example.com/image.jpg", | |
| 445 | + "sales": 1000, | |
| 446 | + "min_price": 8999.0, | |
| 447 | + "max_price": 12999.0, | |
| 448 | + "compare_at_price": 12999.0, | |
| 449 | + "sku_prices": [8999.0, 10999.0, 12999.0], | |
| 450 | + "sku_weights": [221, 221, 221], | |
| 451 | + "sku_weight_units": ["g"], | |
| 452 | + "total_inventory": 500, | |
| 453 | + "create_time": "2024-01-01T00:00:00", | |
| 454 | + "update_time": "2024-01-15T10:30:00", | |
| 455 | + "title_embedding": [0.123, 0.456, ...], | |
| 456 | + "skus": [ | |
| 457 | + { | |
| 458 | + "sku_id": "1001", | |
| 459 | + "price": 8999.0, | |
| 460 | + "compare_at_price": 9999.0, | |
| 461 | + "sku_code": "IP15PM-256-BLK", | |
| 462 | + "stock": 100, | |
| 463 | + "weight": 221.0, | |
| 464 | + "weight_unit": "g", | |
| 465 | + "option1_value": "深空黑色", | |
| 466 | + "option2_value": "256GB", | |
| 467 | + "image_src": "https://example.com/sku1.jpg" | |
| 468 | + } | |
| 469 | + ], | |
| 470 | + "specifications": [ | |
| 471 | + {"sku_id": "1001", "name": "颜色", "value": "深空黑色"}, | |
| 472 | + {"sku_id": "1001", "name": "存储容量", "value": "256GB"} | |
| 473 | + ] | |
| 474 | +} | |
| 475 | +``` | |
| 476 | + | |
| 477 | +--- | |
| 478 | + | |
| 479 | +## 9. 关键映射关系总结 | |
| 480 | + | |
| 481 | +### 9.1 类别映射 | |
| 482 | + | |
| 483 | +``` | |
| 484 | +SPU.category_path ("1,2,3") | |
| 485 | + ↓ [解析ID列表] | |
| 486 | +category_ids ["1", "2", "3"] | |
| 487 | + ↓ [通过映射表转换] | |
| 488 | +category_names ["电子产品", "手机", "iPhone"] | |
| 489 | + ↓ [构建字段] | |
| 490 | +category_path_zh: "电子产品/手机/iPhone" | |
| 491 | +category1_name: "电子产品" | |
| 492 | +category2_name: "手机" | |
| 493 | +category3_name: "iPhone" | |
| 494 | +``` | |
| 495 | + | |
| 496 | +### 9.2 SKU 和选项映射 | |
| 497 | + | |
| 498 | +``` | |
| 499 | +Option 表 (position=1, name="颜色") | |
| 500 | + ↓ | |
| 501 | +option1_name: "颜色" | |
| 502 | + | |
| 503 | +SKU 表 (option1="红色", option1="蓝色") | |
| 504 | + ↓ [收集所有值,去重] | |
| 505 | +option1_values: ["红色", "蓝色"] | |
| 506 | + | |
| 507 | +SKU 表 + Option 表 | |
| 508 | + ↓ [组合构建] | |
| 509 | +specifications: [ | |
| 510 | + {name: "颜色", value: "红色", sku_id: "123"}, | |
| 511 | + {name: "颜色", value: "蓝色", sku_id: "124"} | |
| 512 | +] | |
| 513 | +``` | |
| 514 | + | |
| 515 | +### 9.3 价格聚合 | |
| 516 | + | |
| 517 | +``` | |
| 518 | +SKU 表 (price: 99.99, 109.99, 129.99) | |
| 519 | + ↓ [聚合计算] | |
| 520 | +min_price: 99.99 | |
| 521 | +max_price: 129.99 | |
| 522 | +sku_prices: [99.99, 109.99, 129.99] | |
| 523 | +``` | |
| 524 | + | |
| 525 | +--- | |
| 526 | + | |
| 527 | +## 附录:数据表字段对照 | |
| 528 | + | |
| 529 | +### SPU 表主要字段 | |
| 530 | + | |
| 531 | +| 表字段 | ES 字段 | 说明 | | |
| 532 | +|--------|---------|------| | |
| 533 | +| `id` | `spu_id` | 商品ID | | |
| 534 | +| `tenant_id` | `tenant_id` | 租户ID | | |
| 535 | +| `title` | `title_zh/en` | 标题 | | |
| 536 | +| `brief` | `brief_zh/en` | 简介 | | |
| 537 | +| `description` | `description_zh/en` | 描述 | | |
| 538 | +| `vendor` | `vendor_zh/en` | 品牌 | | |
| 539 | +| `tags` | `tags` | 标签 | | |
| 540 | +| `category_path` | `category_path_zh` | 类目路径 | | |
| 541 | +| `category_id` | `category_id` | 类目ID | | |
| 542 | +| `category_level` | `category_level` | 类目层级 | | |
| 543 | +| `image_src` | `image_url` | 主图 | | |
| 544 | +| `fake_sales` | `sales` | 销量 | | |
| 545 | +| `create_time` | `create_time` | 创建时间 | | |
| 546 | +| `update_time` | `update_time` | 更新时间 | | |
| 547 | + | |
| 548 | +### SKU 表主要字段 | |
| 549 | + | |
| 550 | +| 表字段 | ES 字段 | 说明 | | |
| 551 | +|--------|---------|------| | |
| 552 | +| `id` | `skus[].sku_id` | SKU ID | | |
| 553 | +| `price` | `skus[].price`, `min_price`, `max_price`, `sku_prices` | 价格 | | |
| 554 | +| `compare_at_price` | `skus[].compare_at_price`, `compare_at_price` | 原价 | | |
| 555 | +| `sku` | `skus[].sku_code` | SKU编码 | | |
| 556 | +| `inventory_quantity` | `skus[].stock`, `total_inventory` | 库存 | | |
| 557 | +| `weight` | `skus[].weight`, `sku_weights` | 重量 | | |
| 558 | +| `weight_unit` | `skus[].weight_unit`, `sku_weight_units` | 重量单位 | | |
| 559 | +| `option1` | `skus[].option1_value`, `option1_values`, `specifications[].value` | 选项1值 | | |
| 560 | +| `option2` | `skus[].option2_value`, `option2_values`, `specifications[].value` | 选项2值 | | |
| 561 | +| `option3` | `skus[].option3_value`, `option3_values`, `specifications[].value` | 选项3值 | | |
| 562 | +| `image_src` | `skus[].image_src` | SKU图片 | | |
| 563 | + | |
| 564 | +### Option 表主要字段 | |
| 565 | + | |
| 566 | +| 表字段 | ES 字段 | 说明 | | |
| 567 | +|--------|---------|------| | |
| 568 | +| `position` | - | 位置(1/2/3) | | |
| 569 | +| `name` | `option1_name`, `option2_name`, `option3_name`, `specifications[].name` | 选项名称 | | |
| 570 | + | ... | ... |
| ... | ... | @@ -0,0 +1,961 @@ |
| 1 | +# MySQL 到 Elasticsearch 文档映射说明 | |
| 2 | + | |
| 3 | +## 1. 概述 | |
| 4 | + | |
| 5 | +本文档详细说明从 MySQL 数据库到 Elasticsearch 的完整数据转换流程,包括 SQL 查询逻辑、字段映射规则、以及复杂字段(类别、多款式)的处理方法。 | |
| 6 | + | |
| 7 | +### 1.1 数据流转架构 | |
| 8 | + | |
| 9 | +``` | |
| 10 | +MySQL 数据库 | |
| 11 | + ↓ | |
| 12 | +[SQL 查询] → SPU表、SKU表、Option表 | |
| 13 | + ↓ | |
| 14 | +[数据加载] → Pandas DataFrame | |
| 15 | + ↓ | |
| 16 | +[文档转换] → ES 文档字典 | |
| 17 | + ↓ | |
| 18 | +[批量写入] → Elasticsearch | |
| 19 | +``` | |
| 20 | + | |
| 21 | +### 1.2 核心组件 | |
| 22 | + | |
| 23 | +- **IncrementalIndexerService**: 增量索引服务,负责数据获取和索引 | |
| 24 | +- **SPUDocumentTransformer**: 文档转换器,负责将数据库记录转换为 ES 文档 | |
| 25 | +- **BulkIndexer**: 批量写入器,负责将文档批量写入 ES | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## 2. 数据源表结构 | |
| 30 | + | |
| 31 | +### 2.1 SPU 表(shoplazza_product_spu) | |
| 32 | + | |
| 33 | +SPU(Standard Product Unit)表存储商品的基本信息。 | |
| 34 | + | |
| 35 | +**主要字段:** | |
| 36 | + | |
| 37 | +| 字段名 | 类型 | 说明 | | |
| 38 | +|--------|------|------| | |
| 39 | +| `id` | BIGINT | SPU主键ID | | |
| 40 | +| `tenant_id` | BIGINT | 租户ID | | |
| 41 | +| `title` | VARCHAR(500) | 商品标题 | | |
| 42 | +| `brief` | VARCHAR(1000) | 商品简介 | | |
| 43 | +| `description` | TEXT | 商品详细描述 | | |
| 44 | +| `vendor` | VARCHAR(255) | 供应商/品牌 | | |
| 45 | +| `category` | VARCHAR(255) | 类目名称 | | |
| 46 | +| `category_id` | BIGINT | 类目ID | | |
| 47 | +| `category_path` | VARCHAR(500) | 类目路径(逗号分隔的ID列表) | | |
| 48 | +| `category_level` | INT | 类目层级 | | |
| 49 | +| `tags` | TEXT | 标签(逗号分隔) | | |
| 50 | +| `image_src` | VARCHAR(500) | 主图URL | | |
| 51 | +| `fake_sales` | INT | 假销量 | | |
| 52 | +| `create_time` | DATETIME | 创建时间 | | |
| 53 | +| `update_time` | DATETIME | 更新时间 | | |
| 54 | +| `deleted` | BIT(1) | 删除标记 | | |
| 55 | + | |
| 56 | +### 2.2 SKU 表(shoplazza_product_sku) | |
| 57 | + | |
| 58 | +SKU(Stock Keeping Unit)表存储商品的变体信息,一个 SPU 可以有多个 SKU。 | |
| 59 | + | |
| 60 | +**主要字段:** | |
| 61 | + | |
| 62 | +| 字段名 | 类型 | 说明 | | |
| 63 | +|--------|------|------| | |
| 64 | +| `id` | BIGINT | SKU主键ID | | |
| 65 | +| `spu_id` | BIGINT | 关联的SPU ID | | |
| 66 | +| `tenant_id` | BIGINT | 租户ID | | |
| 67 | +| `sku` | VARCHAR(100) | SKU编码 | | |
| 68 | +| `price` | DECIMAL(10,2) | 价格 | | |
| 69 | +| `compare_at_price` | DECIMAL(10,2) | 对比价格(原价) | | |
| 70 | +| `inventory_quantity` | INT | 库存数量 | | |
| 71 | +| `weight` | DECIMAL(10,2) | 重量 | | |
| 72 | +| `weight_unit` | VARCHAR(10) | 重量单位 | | |
| 73 | +| `option1` | VARCHAR(255) | 选项1的值(如:颜色-红色) | | |
| 74 | +| `option2` | VARCHAR(255) | 选项2的值(如:尺寸-L) | | |
| 75 | +| `option3` | VARCHAR(255) | 选项3的值(如:材质-棉) | | |
| 76 | +| `image_src` | VARCHAR(500) | SKU图片URL | | |
| 77 | +| `deleted` | BIT(1) | 删除标记 | | |
| 78 | + | |
| 79 | +### 2.3 Option 表(shoplazza_product_option) | |
| 80 | + | |
| 81 | +Option 表存储选项的名称定义,用于定义 SKU 的选项维度(如:颜色、尺寸、材质)。 | |
| 82 | + | |
| 83 | +**主要字段:** | |
| 84 | + | |
| 85 | +| 字段名 | 类型 | 说明 | | |
| 86 | +|--------|------|------| | |
| 87 | +| `id` | BIGINT | Option主键ID | | |
| 88 | +| `spu_id` | BIGINT | 关联的SPU ID | | |
| 89 | +| `tenant_id` | BIGINT | 租户ID | | |
| 90 | +| `position` | INT | 位置序号(1、2、3对应option1、option2、option3) | | |
| 91 | +| `name` | VARCHAR(255) | 选项名称(如:"颜色"、"尺寸") | | |
| 92 | +| `values` | TEXT | 选项值列表(JSON格式) | | |
| 93 | +| `deleted` | BIT(1) | 删除标记 | | |
| 94 | + | |
| 95 | +--- | |
| 96 | + | |
| 97 | +## 3. SQL 查询逻辑 | |
| 98 | + | |
| 99 | +### 3.1 批量查询 SPU 数据 | |
| 100 | + | |
| 101 | +**方法:** `_load_spus_for_spu_ids(tenant_id, spu_ids, include_deleted=True)` | |
| 102 | + | |
| 103 | +**SQL 查询:** | |
| 104 | + | |
| 105 | +```sql | |
| 106 | +SELECT | |
| 107 | + id, shop_id, shoplazza_id, title, brief, description, | |
| 108 | + spu, vendor, vendor_url, | |
| 109 | + image_src, image_width, image_height, image_path, image_alt, | |
| 110 | + tags, note, category, category_id, category_google_id, | |
| 111 | + category_level, category_path, | |
| 112 | + fake_sales, display_fake_sales, | |
| 113 | + tenant_id, creator, create_time, updater, update_time, deleted | |
| 114 | +FROM shoplazza_product_spu | |
| 115 | +WHERE tenant_id = :tenant_id | |
| 116 | + AND id IN :spu_ids | |
| 117 | + [AND deleted = 0] -- 如果 include_deleted=False | |
| 118 | +``` | |
| 119 | + | |
| 120 | +**查询特点:** | |
| 121 | +- 使用 `IN` 子句批量查询多个 SPU | |
| 122 | +- 支持查询已删除的记录(用于检测删除状态) | |
| 123 | +- 使用 SQLAlchemy 的 `bindparam` 和 `expanding=True` 处理动态 IN 列表 | |
| 124 | + | |
| 125 | +**代码位置:** `indexer/incremental_service.py:223-262` | |
| 126 | + | |
| 127 | +### 3.2 批量查询 SKU 数据 | |
| 128 | + | |
| 129 | +**方法:** `_load_skus_for_spu_ids(tenant_id, spu_ids)` | |
| 130 | + | |
| 131 | +**SQL 查询:** | |
| 132 | + | |
| 133 | +```sql | |
| 134 | +SELECT | |
| 135 | + id, spu_id, shop_id, shoplazza_id, shoplazza_product_id, | |
| 136 | + shoplazza_image_id, title, sku, barcode, position, | |
| 137 | + price, compare_at_price, cost_price, | |
| 138 | + option1, option2, option3, | |
| 139 | + inventory_quantity, weight, weight_unit, image_src, | |
| 140 | + wholesale_price, note, extend, | |
| 141 | + shoplazza_created_at, shoplazza_updated_at, tenant_id, | |
| 142 | + creator, create_time, updater, update_time, deleted | |
| 143 | +FROM shoplazza_product_sku | |
| 144 | +WHERE tenant_id = :tenant_id | |
| 145 | + AND deleted = 0 | |
| 146 | + AND spu_id IN :spu_ids | |
| 147 | +``` | |
| 148 | + | |
| 149 | +**查询特点:** | |
| 150 | +- 批量查询多个 SPU 的所有 SKU | |
| 151 | +- 只查询未删除的 SKU(`deleted = 0`) | |
| 152 | +- 结果按 `spu_id` 分组处理 | |
| 153 | + | |
| 154 | +**代码位置:** `indexer/incremental_service.py:264-288` | |
| 155 | + | |
| 156 | +### 3.3 批量查询 Option 数据 | |
| 157 | + | |
| 158 | +**方法:** `_load_options_for_spu_ids(tenant_id, spu_ids)` | |
| 159 | + | |
| 160 | +**SQL 查询:** | |
| 161 | + | |
| 162 | +```sql | |
| 163 | +SELECT | |
| 164 | + id, spu_id, shop_id, shoplazza_id, shoplazza_product_id, | |
| 165 | + position, name, `values`, tenant_id, | |
| 166 | + creator, create_time, updater, update_time, deleted | |
| 167 | +FROM shoplazza_product_option | |
| 168 | +WHERE tenant_id = :tenant_id | |
| 169 | + AND deleted = 0 | |
| 170 | + AND spu_id IN :spu_ids | |
| 171 | +ORDER BY spu_id, position | |
| 172 | +``` | |
| 173 | + | |
| 174 | +**查询特点:** | |
| 175 | +- 批量查询多个 SPU 的所有 Option | |
| 176 | +- 按 `spu_id` 和 `position` 排序,确保选项顺序一致 | |
| 177 | +- `position` 字段对应 SKU 表中的 `option1`、`option2`、`option3` | |
| 178 | + | |
| 179 | +**代码位置:** `indexer/incremental_service.py:290-310` | |
| 180 | + | |
| 181 | +### 3.4 分类映射加载 | |
| 182 | + | |
| 183 | +**方法:** `load_category_mapping(db_engine)` | |
| 184 | + | |
| 185 | +**SQL 查询:** | |
| 186 | + | |
| 187 | +```sql | |
| 188 | +SELECT DISTINCT | |
| 189 | + category_id, | |
| 190 | + category | |
| 191 | +FROM shoplazza_product_spu | |
| 192 | +WHERE deleted = 0 AND category_id IS NOT NULL | |
| 193 | +``` | |
| 194 | + | |
| 195 | +**查询特点:** | |
| 196 | +- 全局加载所有租户的分类映射(category_id → category_name) | |
| 197 | +- 在服务初始化时预加载,避免重复查询 | |
| 198 | +- 用于将 `category_path` 中的 ID 转换为名称 | |
| 199 | + | |
| 200 | +**代码位置:** `indexer/indexing_utils.py:17-51` | |
| 201 | + | |
| 202 | +--- | |
| 203 | + | |
| 204 | +## 4. 字段映射详细说明 | |
| 205 | + | |
| 206 | +### 4.1 基础字段映射 | |
| 207 | + | |
| 208 | +#### 4.1.1 标识字段 | |
| 209 | + | |
| 210 | +| ES 字段 | 数据来源 | 转换逻辑 | | |
| 211 | +|---------|----------|----------| | |
| 212 | +| `tenant_id` | `spu_row['tenant_id']` | 直接转换为字符串:`str(tenant_id)` | | |
| 213 | +| `spu_id` | `spu_row['id']` | 直接转换为字符串:`str(spu_id)` | | |
| 214 | + | |
| 215 | +#### 4.1.2 文本字段(多语言处理) | |
| 216 | + | |
| 217 | +文本字段支持中英文双语,根据租户配置进行自动翻译。 | |
| 218 | + | |
| 219 | +**字段列表:** | |
| 220 | +- `title_zh` / `title_en` | |
| 221 | +- `brief_zh` / `brief_en` | |
| 222 | +- `description_zh` / `description_en` | |
| 223 | +- `vendor_zh` / `vendor_en` | |
| 224 | + | |
| 225 | +**填充逻辑:** | |
| 226 | + | |
| 227 | +1. **获取租户主语言**:从 `tenant_config` 读取 `primary_language`(默认 'zh') | |
| 228 | + | |
| 229 | +2. **翻译处理**: | |
| 230 | + - 如果配置了 `translate_to_en=true` 且主语言不是 'en',则翻译到英文 | |
| 231 | + - 如果配置了 `translate_to_zh=true` 且主语言不是 'zh',则翻译到中文 | |
| 232 | + - 使用 `Translator.translate_for_indexing()` 方法进行翻译 | |
| 233 | + | |
| 234 | +3. **字段填充**: | |
| 235 | + ```python | |
| 236 | + # 示例:title 字段 | |
| 237 | + if pd.notna(spu_row.get('title')): | |
| 238 | + title_text = str(spu_row['title']) | |
| 239 | + if self.translator: | |
| 240 | + translations = self.translator.translate_for_indexing( | |
| 241 | + title_text, | |
| 242 | + shop_language=primary_lang, | |
| 243 | + source_lang=primary_lang, | |
| 244 | + translate_to_en=translate_to_en, | |
| 245 | + translate_to_zh=translate_to_zh, | |
| 246 | + ) | |
| 247 | + doc['title_zh'] = translations.get('zh') or (title_text if primary_lang == 'zh' else None) | |
| 248 | + doc['title_en'] = translations.get('en') or (title_text if primary_lang == 'en' else None) | |
| 249 | + ``` | |
| 250 | + | |
| 251 | +**代码位置:** `indexer/document_transformer.py:168-298` | |
| 252 | + | |
| 253 | +#### 4.1.3 标签字段 | |
| 254 | + | |
| 255 | +| ES 字段 | 数据来源 | 转换逻辑 | | |
| 256 | +|---------|----------|----------| | |
| 257 | +| `tags` | `spu_row['tags']` | 逗号分隔字符串 → 列表:`[tag.strip() for tag in tags_str.split(',') if tag.strip()]` | | |
| 258 | + | |
| 259 | +**示例:** | |
| 260 | +- 数据库:`"标签1,标签2,标签3"` | |
| 261 | +- ES:`["标签1", "标签2", "标签3"]` | |
| 262 | + | |
| 263 | +#### 4.1.4 图片字段 | |
| 264 | + | |
| 265 | +| ES 字段 | 数据来源 | 转换逻辑 | | |
| 266 | +|---------|----------|----------| | |
| 267 | +| `image_url` | `spu_row['image_src']` | 如果 URL 不以 `http` 开头,添加 `//` 前缀 | | |
| 268 | + | |
| 269 | +**代码位置:** `indexer/document_transformer.py:390-396` | |
| 270 | + | |
| 271 | +#### 4.1.5 销量字段 | |
| 272 | + | |
| 273 | +| ES 字段 | 数据来源 | 转换逻辑 | | |
| 274 | +|---------|----------|----------| | |
| 275 | +| `sales` | `spu_row['fake_sales']` | 转换为整数,如果为空或转换失败则设为 0 | | |
| 276 | + | |
| 277 | +#### 4.1.6 时间字段 | |
| 278 | + | |
| 279 | +| ES 字段 | 数据来源 | 转换逻辑 | | |
| 280 | +|---------|----------|----------| | |
| 281 | +| `create_time` | `spu_row['create_time']` | 转换为 ISO 格式字符串:`datetime.isoformat()` | | |
| 282 | +| `update_time` | `spu_row['update_time']` | 转换为 ISO 格式字符串:`datetime.isoformat()` | | |
| 283 | + | |
| 284 | +--- | |
| 285 | + | |
| 286 | +### 4.2 类别(Category)字段映射 | |
| 287 | + | |
| 288 | +类别字段是复杂字段,涉及多级分类的解析和映射。 | |
| 289 | + | |
| 290 | +#### 4.2.1 类别数据来源 | |
| 291 | + | |
| 292 | +类别信息主要来自 SPU 表的以下字段: | |
| 293 | +- `category_path`: 类目路径,逗号分隔的类目ID列表(如:`"1,2,3"`) | |
| 294 | +- `category`: 类目名称(备用字段) | |
| 295 | +- `category_id`: 当前类目ID | |
| 296 | +- `category_level`: 类目层级 | |
| 297 | + | |
| 298 | +#### 4.2.2 类别映射流程 | |
| 299 | + | |
| 300 | +**步骤 1:加载分类映射** | |
| 301 | + | |
| 302 | +在服务初始化时,从数据库加载全局分类映射: | |
| 303 | + | |
| 304 | +```python | |
| 305 | +category_id_to_name = load_category_mapping(db_engine) | |
| 306 | +# 结果:{"1": "电子产品", "2": "手机", "3": "iPhone"} | |
| 307 | +``` | |
| 308 | + | |
| 309 | +**步骤 2:解析 category_path** | |
| 310 | + | |
| 311 | +```python | |
| 312 | +if pd.notna(spu_row.get('category_path')): | |
| 313 | + category_path = str(spu_row['category_path']) # 例如:"1,2,3" | |
| 314 | + category_ids = [cid.strip() for cid in category_path.split(',') if cid.strip()] | |
| 315 | + # 结果:["1", "2", "3"] | |
| 316 | +``` | |
| 317 | + | |
| 318 | +**步骤 3:ID 转名称** | |
| 319 | + | |
| 320 | +```python | |
| 321 | +category_names = [] | |
| 322 | +missing_ids = [] | |
| 323 | +for cid in category_ids: | |
| 324 | + if cid in self.category_id_to_name: | |
| 325 | + category_names.append(self.category_id_to_name[cid]) | |
| 326 | + else: | |
| 327 | + missing_ids.append(cid) | |
| 328 | +``` | |
| 329 | + | |
| 330 | +**步骤 4:数据质量检查** | |
| 331 | + | |
| 332 | +如果存在缺失的类目ID(在映射中找不到),则: | |
| 333 | +- 记录错误日志 | |
| 334 | +- **不写入任何类目字段**(视为没有类目) | |
| 335 | +- 不阻塞索引流程 | |
| 336 | + | |
| 337 | +**步骤 5:填充 ES 字段** | |
| 338 | + | |
| 339 | +```python | |
| 340 | +if category_names: | |
| 341 | + # 构建类目路径字符串 | |
| 342 | + category_path_str = '/'.join(category_names) | |
| 343 | + doc['category_path_zh'] = category_path_str # 例如:"电子产品/手机/iPhone" | |
| 344 | + | |
| 345 | + # 填充分层类目名称 | |
| 346 | + if len(category_names) > 0: | |
| 347 | + doc['category1_name'] = category_names[0] # "电子产品" | |
| 348 | + if len(category_names) > 1: | |
| 349 | + doc['category2_name'] = category_names[1] # "手机" | |
| 350 | + if len(category_names) > 2: | |
| 351 | + doc['category3_name'] = category_names[2] # "iPhone" | |
| 352 | +``` | |
| 353 | + | |
| 354 | +**步骤 6:备用处理(category_path 为空时)** | |
| 355 | + | |
| 356 | +如果 `category_path` 为空,使用 `category` 字段作为备选: | |
| 357 | + | |
| 358 | +```python | |
| 359 | +elif pd.notna(spu_row.get('category')): | |
| 360 | + category = str(spu_row['category']) | |
| 361 | + doc['category_name_zh'] = category | |
| 362 | + | |
| 363 | + # 尝试从 category 字段解析多级分类(如果包含 "/") | |
| 364 | + if '/' in category: | |
| 365 | + path_parts = category.split('/') | |
| 366 | + if len(path_parts) > 0: | |
| 367 | + doc['category1_name'] = path_parts[0].strip() | |
| 368 | + if len(path_parts) > 1: | |
| 369 | + doc['category2_name'] = path_parts[1].strip() | |
| 370 | + if len(path_parts) > 2: | |
| 371 | + doc['category3_name'] = path_parts[2].strip() | |
| 372 | +``` | |
| 373 | + | |
| 374 | +#### 4.2.3 类别相关 ES 字段 | |
| 375 | + | |
| 376 | +| ES 字段 | 类型 | 说明 | | |
| 377 | +|---------|------|------| | |
| 378 | +| `category_path_zh` | text | 类目路径字符串(如:"电子产品/手机/iPhone") | | |
| 379 | +| `category_path_en` | text | 类目路径英文(暂未实现) | | |
| 380 | +| `category1_name` | keyword | 一级类目名称 | | |
| 381 | +| `category2_name` | keyword | 二级类目名称 | | |
| 382 | +| `category3_name` | keyword | 三级类目名称 | | |
| 383 | +| `category_id` | keyword | 当前类目ID | | |
| 384 | +| `category_level` | integer | 类目层级 | | |
| 385 | + | |
| 386 | +**代码位置:** `indexer/document_transformer.py:300-376` | |
| 387 | + | |
| 388 | +--- | |
| 389 | + | |
| 390 | +### 4.3 多款式(SKU/Options)字段映射 | |
| 391 | + | |
| 392 | +多款式字段是最复杂的部分,涉及 SKU 嵌套结构、选项值提取、规格构建等。 | |
| 393 | + | |
| 394 | +#### 4.3.1 SKU 数据结构 | |
| 395 | + | |
| 396 | +一个 SPU 可以有多个 SKU,每个 SKU 代表一个商品变体(如:红色-L码、蓝色-M码)。 | |
| 397 | + | |
| 398 | +**ES 中的 SKU 嵌套结构:** | |
| 399 | + | |
| 400 | +```json | |
| 401 | +{ | |
| 402 | + "skus": [ | |
| 403 | + { | |
| 404 | + "sku_id": "123", | |
| 405 | + "price": 99.99, | |
| 406 | + "compare_at_price": 129.99, | |
| 407 | + "sku_code": "SKU001", | |
| 408 | + "stock": 100, | |
| 409 | + "weight": 0.5, | |
| 410 | + "weight_unit": "kg", | |
| 411 | + "option1_value": "红色", | |
| 412 | + "option2_value": "L", | |
| 413 | + "option3_value": "棉", | |
| 414 | + "image_src": "https://..." | |
| 415 | + }, | |
| 416 | + { | |
| 417 | + "sku_id": "124", | |
| 418 | + "price": 99.99, | |
| 419 | + "compare_at_price": 129.99, | |
| 420 | + "sku_code": "SKU002", | |
| 421 | + "stock": 50, | |
| 422 | + "weight": 0.5, | |
| 423 | + "weight_unit": "kg", | |
| 424 | + "option1_value": "蓝色", | |
| 425 | + "option2_value": "M", | |
| 426 | + "option3_value": "棉", | |
| 427 | + "image_src": "https://..." | |
| 428 | + } | |
| 429 | + ] | |
| 430 | +} | |
| 431 | +``` | |
| 432 | + | |
| 433 | +#### 4.3.2 SKU 处理流程 | |
| 434 | + | |
| 435 | +**步骤 1:构建 Option 名称映射** | |
| 436 | + | |
| 437 | +从 Option 表获取选项名称: | |
| 438 | + | |
| 439 | +```python | |
| 440 | +option_name_map = {} | |
| 441 | +if not options.empty: | |
| 442 | + for _, opt_row in options.iterrows(): | |
| 443 | + position = opt_row.get('position') # 1, 2, 3 | |
| 444 | + name = opt_row.get('name') # "颜色", "尺寸", "材质" | |
| 445 | + if pd.notna(position) and pd.notna(name): | |
| 446 | + option_name_map[int(position)] = str(name) | |
| 447 | +# 结果:{1: "颜色", 2: "尺寸", 3: "材质"} | |
| 448 | +``` | |
| 449 | + | |
| 450 | +**步骤 2:转换每个 SKU** | |
| 451 | + | |
| 452 | +```python | |
| 453 | +for _, sku_row in skus.iterrows(): | |
| 454 | + sku_data = self._transform_sku_row(sku_row, option_name_map) | |
| 455 | + if sku_data: | |
| 456 | + skus_list.append(sku_data) | |
| 457 | +``` | |
| 458 | + | |
| 459 | +**SKU 转换逻辑:** | |
| 460 | + | |
| 461 | +```python | |
| 462 | +def _transform_sku_row(self, sku_row, option_name_map): | |
| 463 | + sku_data = { | |
| 464 | + 'sku_id': str(sku_row['id']), | |
| 465 | + 'price': float(sku_row['price']) if pd.notna(sku_row.get('price')) else None, | |
| 466 | + 'compare_at_price': float(sku_row['compare_at_price']) if pd.notna(sku_row.get('compare_at_price')) else None, | |
| 467 | + 'sku_code': str(sku_row['sku']) if pd.notna(sku_row.get('sku')) else None, | |
| 468 | + 'stock': int(sku_row['inventory_quantity']) if pd.notna(sku_row.get('inventory_quantity')) else 0, | |
| 469 | + 'weight': float(sku_row['weight']) if pd.notna(sku_row.get('weight')) else None, | |
| 470 | + 'weight_unit': str(sku_row['weight_unit']) if pd.notna(sku_row.get('weight_unit')) else None, | |
| 471 | + 'image_src': str(sku_row['image_src']) if pd.notna(sku_row.get('image_src')) else None, | |
| 472 | + } | |
| 473 | + | |
| 474 | + # 填充选项值 | |
| 475 | + if pd.notna(sku_row.get('option1')): | |
| 476 | + sku_data['option1_value'] = str(sku_row['option1']) | |
| 477 | + if pd.notna(sku_row.get('option2')): | |
| 478 | + sku_data['option2_value'] = str(sku_row['option2']) | |
| 479 | + if pd.notna(sku_row.get('option3')): | |
| 480 | + sku_data['option3_value'] = str(sku_row['option3']) | |
| 481 | + | |
| 482 | + return sku_data | |
| 483 | +``` | |
| 484 | + | |
| 485 | +**步骤 3:收集聚合信息** | |
| 486 | + | |
| 487 | +在处理 SKU 的同时,收集价格、重量、库存等聚合信息: | |
| 488 | + | |
| 489 | +```python | |
| 490 | +prices = [] | |
| 491 | +compare_prices = [] | |
| 492 | +sku_prices = [] | |
| 493 | +sku_weights = [] | |
| 494 | +sku_weight_units = [] | |
| 495 | +total_inventory = 0 | |
| 496 | + | |
| 497 | +for sku_data in skus_list: | |
| 498 | + if 'price' in sku_data and sku_data['price'] is not None: | |
| 499 | + prices.append(sku_data['price']) | |
| 500 | + sku_prices.append(sku_data['price']) | |
| 501 | + | |
| 502 | + if 'compare_at_price' in sku_data and sku_data['compare_at_price'] is not None: | |
| 503 | + compare_prices.append(sku_data['compare_at_price']) | |
| 504 | + | |
| 505 | + if 'weight' in sku_data and sku_data['weight'] is not None: | |
| 506 | + sku_weights.append(int(float(sku_data['weight']))) | |
| 507 | + | |
| 508 | + if 'weight_unit' in sku_data and sku_data['weight_unit']: | |
| 509 | + sku_weight_units.append(sku_data['weight_unit']) | |
| 510 | + | |
| 511 | + if 'stock' in sku_data and sku_data['stock'] is not None: | |
| 512 | + total_inventory += sku_data['stock'] | |
| 513 | +``` | |
| 514 | + | |
| 515 | +**步骤 4:填充 ES 字段** | |
| 516 | + | |
| 517 | +```python | |
| 518 | +doc['skus'] = skus_list # 嵌套 SKU 数组 | |
| 519 | + | |
| 520 | +# 价格范围 | |
| 521 | +if prices: | |
| 522 | + doc['min_price'] = float(min(prices)) | |
| 523 | + doc['max_price'] = float(max(prices)) | |
| 524 | +else: | |
| 525 | + doc['min_price'] = 0.0 | |
| 526 | + doc['max_price'] = 0.0 | |
| 527 | + | |
| 528 | +if compare_prices: | |
| 529 | + doc['compare_at_price'] = float(max(compare_prices)) | |
| 530 | + | |
| 531 | +# SKU 扁平化字段(用于过滤和聚合) | |
| 532 | +doc['sku_prices'] = sku_prices # [99.99, 99.99, 129.99] | |
| 533 | +doc['sku_weights'] = sku_weights # [500, 500, 600] | |
| 534 | +doc['sku_weight_units'] = list(set(sku_weight_units)) # ["kg"] | |
| 535 | +doc['total_inventory'] = total_inventory # 150 | |
| 536 | +``` | |
| 537 | + | |
| 538 | +**代码位置:** `indexer/document_transformer.py:398-480` | |
| 539 | + | |
| 540 | +#### 4.3.3 选项名称字段 | |
| 541 | + | |
| 542 | +从 Option 表获取选项名称,填充到 ES 文档的顶层: | |
| 543 | + | |
| 544 | +```python | |
| 545 | +def _fill_option_names(self, doc, options): | |
| 546 | + if not options.empty: | |
| 547 | + sorted_options = options.sort_values('position') | |
| 548 | + if len(sorted_options) > 0 and pd.notna(sorted_options.iloc[0].get('name')): | |
| 549 | + doc['option1_name'] = str(sorted_options.iloc[0]['name']) # "颜色" | |
| 550 | + if len(sorted_options) > 1 and pd.notna(sorted_options.iloc[1].get('name')): | |
| 551 | + doc['option2_name'] = str(sorted_options.iloc[1]['name']) # "尺寸" | |
| 552 | + if len(sorted_options) > 2 and pd.notna(sorted_options.iloc[2].get('name')): | |
| 553 | + doc['option3_name'] = str(sorted_options.iloc[2]['name']) # "材质" | |
| 554 | +``` | |
| 555 | + | |
| 556 | +**ES 字段:** | |
| 557 | +- `option1_name`: 选项1的名称(如:"颜色") | |
| 558 | +- `option2_name`: 选项2的名称(如:"尺寸") | |
| 559 | +- `option3_name`: 选项3的名称(如:"材质") | |
| 560 | + | |
| 561 | +**代码位置:** `indexer/document_transformer.py:378-388` | |
| 562 | + | |
| 563 | +#### 4.3.4 选项值字段 | |
| 564 | + | |
| 565 | +从所有 SKU 中提取选项值,去重后填充到 ES 文档: | |
| 566 | + | |
| 567 | +```python | |
| 568 | +def _fill_option_values(self, doc, skus): | |
| 569 | + option1_values = [] | |
| 570 | + option2_values = [] | |
| 571 | + option3_values = [] | |
| 572 | + | |
| 573 | + for _, sku_row in skus.iterrows(): | |
| 574 | + if pd.notna(sku_row.get('option1')): | |
| 575 | + option1_values.append(str(sku_row['option1'])) | |
| 576 | + if pd.notna(sku_row.get('option2')): | |
| 577 | + option2_values.append(str(sku_row['option2'])) | |
| 578 | + if pd.notna(sku_row.get('option3')): | |
| 579 | + option3_values.append(str(sku_row['option3'])) | |
| 580 | + | |
| 581 | + # 根据配置决定是否写入索引(searchable_option_dimensions) | |
| 582 | + if 'option1' in self.searchable_option_dimensions: | |
| 583 | + doc['option1_values'] = list(set(option1_values)) # ["红色", "蓝色", "绿色"] | |
| 584 | + else: | |
| 585 | + doc['option1_values'] = [] | |
| 586 | + | |
| 587 | + # 同样处理 option2_values 和 option3_values | |
| 588 | +``` | |
| 589 | + | |
| 590 | +**ES 字段:** | |
| 591 | +- `option1_values`: 选项1的所有值列表(如:`["红色", "蓝色", "绿色"]`) | |
| 592 | +- `option2_values`: 选项2的所有值列表(如:`["S", "M", "L"]`) | |
| 593 | +- `option3_values`: 选项3的所有值列表(如:`["棉", "涤纶"]`) | |
| 594 | + | |
| 595 | +**注意:** 只有配置在 `searchable_option_dimensions` 中的选项才会被索引。 | |
| 596 | + | |
| 597 | +**代码位置:** `indexer/document_transformer.py:482-510` | |
| 598 | + | |
| 599 | +#### 4.3.5 规格(Specifications)字段 | |
| 600 | + | |
| 601 | +规格字段用于支持按规格过滤和分面搜索,将 SKU 的选项值结构化存储。 | |
| 602 | + | |
| 603 | +**构建逻辑:** | |
| 604 | + | |
| 605 | +```python | |
| 606 | +specifications = [] | |
| 607 | + | |
| 608 | +for _, sku_row in skus.iterrows(): | |
| 609 | + sku_id = str(sku_row['id']) | |
| 610 | + | |
| 611 | + # 如果 SKU 有 option1 且 Option 表中有对应的名称 | |
| 612 | + if pd.notna(sku_row.get('option1')) and 1 in option_name_map: | |
| 613 | + specifications.append({ | |
| 614 | + 'sku_id': sku_id, | |
| 615 | + 'name': option_name_map[1], # "颜色" | |
| 616 | + 'value': str(sku_row['option1']) # "红色" | |
| 617 | + }) | |
| 618 | + | |
| 619 | + # 同样处理 option2 和 option3 | |
| 620 | + if pd.notna(sku_row.get('option2')) and 2 in option_name_map: | |
| 621 | + specifications.append({ | |
| 622 | + 'sku_id': sku_id, | |
| 623 | + 'name': option_name_map[2], # "尺寸" | |
| 624 | + 'value': str(sku_row['option2']) # "L" | |
| 625 | + }) | |
| 626 | + | |
| 627 | + if pd.notna(sku_row.get('option3')) and 3 in option_name_map: | |
| 628 | + specifications.append({ | |
| 629 | + 'sku_id': sku_id, | |
| 630 | + 'name': option_name_map[3], # "材质" | |
| 631 | + 'value': str(sku_row['option3']) # "棉" | |
| 632 | + }) | |
| 633 | + | |
| 634 | +doc['specifications'] = specifications | |
| 635 | +``` | |
| 636 | + | |
| 637 | +**ES 字段结构:** | |
| 638 | + | |
| 639 | +```json | |
| 640 | +{ | |
| 641 | + "specifications": [ | |
| 642 | + {"sku_id": "123", "name": "颜色", "value": "红色"}, | |
| 643 | + {"sku_id": "123", "name": "尺寸", "value": "L"}, | |
| 644 | + {"sku_id": "123", "name": "材质", "value": "棉"}, | |
| 645 | + {"sku_id": "124", "name": "颜色", "value": "蓝色"}, | |
| 646 | + {"sku_id": "124", "name": "尺寸", "value": "M"}, | |
| 647 | + {"sku_id": "124", "name": "材质", "value": "棉"} | |
| 648 | + ] | |
| 649 | +} | |
| 650 | +``` | |
| 651 | + | |
| 652 | +**用途:** | |
| 653 | +- 支持按规格过滤:`{"specifications": {"name": "颜色", "value": "红色"}}` | |
| 654 | +- 支持规格分面:统计每个规格值的数量 | |
| 655 | + | |
| 656 | +**代码位置:** `indexer/document_transformer.py:459-478` | |
| 657 | + | |
| 658 | +--- | |
| 659 | + | |
| 660 | +### 4.4 向量字段映射 | |
| 661 | + | |
| 662 | +#### 4.4.1 标题向量(title_embedding) | |
| 663 | + | |
| 664 | +**生成时机:** | |
| 665 | +- 在增量索引中,批量生成 embedding(性能优化) | |
| 666 | +- 在单条查询中,按需生成 | |
| 667 | + | |
| 668 | +**生成逻辑:** | |
| 669 | + | |
| 670 | +```python | |
| 671 | +# 批量生成(增量索引) | |
| 672 | +if enable_embedding and encoder and documents: | |
| 673 | + title_texts = [] | |
| 674 | + title_doc_indices = [] | |
| 675 | + for i, (_, doc) in enumerate(documents): | |
| 676 | + title_text = doc.get("title_en") or doc.get("title_zh") | |
| 677 | + if title_text and str(title_text).strip(): | |
| 678 | + title_texts.append(str(title_text)) | |
| 679 | + title_doc_indices.append(i) | |
| 680 | + | |
| 681 | + if title_texts: | |
| 682 | + embeddings = encoder.encode_batch(title_texts, batch_size=32) | |
| 683 | + for j, emb in enumerate(embeddings): | |
| 684 | + doc_idx = title_doc_indices[j] | |
| 685 | + if isinstance(emb, np.ndarray): | |
| 686 | + documents[doc_idx][1]["title_embedding"] = emb.tolist() | |
| 687 | +``` | |
| 688 | + | |
| 689 | +**ES 字段:** | |
| 690 | +- `title_embedding`: 1024 维浮点数组,用于语义搜索 | |
| 691 | + | |
| 692 | +**代码位置:** | |
| 693 | +- 批量生成:`indexer/incremental_service.py:558-576` | |
| 694 | +- 单条生成:`indexer/document_transformer.py:586-619` | |
| 695 | + | |
| 696 | +--- | |
| 697 | + | |
| 698 | +## 5. 数据转换完整流程 | |
| 699 | + | |
| 700 | +### 5.1 增量索引流程 | |
| 701 | + | |
| 702 | +**入口方法:** `IncrementalIndexerService.index_spus_to_es()` | |
| 703 | + | |
| 704 | +**流程步骤:** | |
| 705 | + | |
| 706 | +1. **参数处理** | |
| 707 | + - 去重 SPU ID 列表 | |
| 708 | + - 生成索引名称(如果未提供) | |
| 709 | + | |
| 710 | +2. **显式删除处理**(如果提供了 `delete_spu_ids`) | |
| 711 | + - 遍历 `delete_spu_ids`,从 ES 中删除对应文档 | |
| 712 | + | |
| 713 | +3. **批量加载数据** | |
| 714 | + - 调用 `_load_spus_for_spu_ids()` 批量加载 SPU 数据 | |
| 715 | + - 调用 `_load_skus_for_spu_ids()` 批量加载 SKU 数据 | |
| 716 | + - 调用 `_load_options_for_spu_ids()` 批量加载 Option 数据 | |
| 717 | + | |
| 718 | +4. **自动删除检测** | |
| 719 | + - 检查 SPU 是否在数据库中存在 | |
| 720 | + - 检查 SPU 的 `deleted` 字段 | |
| 721 | + - 如果不存在或已删除,从 ES 中删除对应文档 | |
| 722 | + | |
| 723 | +5. **数据分组** | |
| 724 | + - 将 SKU 按 `spu_id` 分组 | |
| 725 | + - 将 Option 按 `spu_id` 分组 | |
| 726 | + | |
| 727 | +6. **文档转换** | |
| 728 | + - 遍历每个 SPU | |
| 729 | + - 调用 `transformer.transform_spu_to_doc()` 转换为 ES 文档 | |
| 730 | + - 收集所有文档 | |
| 731 | + | |
| 732 | +7. **批量生成 Embedding**(如果启用) | |
| 733 | + - 收集所有文档的标题文本 | |
| 734 | + - 批量调用 `encoder.encode_batch()` 生成 embedding | |
| 735 | + - 填充到对应文档 | |
| 736 | + | |
| 737 | +8. **批量写入 ES** | |
| 738 | + - 使用 `BulkIndexer` 批量写入文档 | |
| 739 | + - 使用 `spu_id` 作为文档 ID | |
| 740 | + | |
| 741 | +9. **返回结果** | |
| 742 | + - 返回成功/失败统计 | |
| 743 | + - 返回每个 SPU 的处理状态 | |
| 744 | + | |
| 745 | +**代码位置:** `indexer/incremental_service.py:391-679` | |
| 746 | + | |
| 747 | +### 5.2 文档转换流程 | |
| 748 | + | |
| 749 | +**入口方法:** `SPUDocumentTransformer.transform_spu_to_doc()` | |
| 750 | + | |
| 751 | +**流程步骤:** | |
| 752 | + | |
| 753 | +1. **初始化文档字典** | |
| 754 | + ```python | |
| 755 | + doc = {} | |
| 756 | + doc['tenant_id'] = str(tenant_id) | |
| 757 | + doc['spu_id'] = str(spu_id) | |
| 758 | + ``` | |
| 759 | + | |
| 760 | +2. **填充文本字段**(多语言) | |
| 761 | + - 调用 `_fill_text_fields()` 填充 title、brief、description、vendor | |
| 762 | + | |
| 763 | +3. **填充标签** | |
| 764 | + - 解析 `tags` 字段,转换为列表 | |
| 765 | + | |
| 766 | +4. **填充类别字段** | |
| 767 | + - 调用 `_fill_category_fields()` 处理类别映射 | |
| 768 | + | |
| 769 | +5. **填充选项名称** | |
| 770 | + - 调用 `_fill_option_names()` 从 Option 表获取选项名称 | |
| 771 | + | |
| 772 | +6. **填充图片 URL** | |
| 773 | + - 调用 `_fill_image_url()` 处理图片 URL | |
| 774 | + | |
| 775 | +7. **填充销量** | |
| 776 | + - 转换 `fake_sales` 为整数 | |
| 777 | + | |
| 778 | +8. **处理 SKU** | |
| 779 | + - 调用 `_process_skus()` 处理所有 SKU | |
| 780 | + - 构建 `skus` 嵌套数组 | |
| 781 | + - 构建 `specifications` 数组 | |
| 782 | + - 计算价格范围、总库存等聚合字段 | |
| 783 | + | |
| 784 | +9. **填充选项值** | |
| 785 | + - 调用 `_fill_option_values()` 提取所有选项值 | |
| 786 | + | |
| 787 | +10. **填充时间字段** | |
| 788 | + - 转换 `create_time` 和 `update_time` 为 ISO 格式 | |
| 789 | + | |
| 790 | +11. **返回文档** | |
| 791 | + ```python | |
| 792 | + return doc | |
| 793 | + ``` | |
| 794 | + | |
| 795 | +**代码位置:** `indexer/document_transformer.py:57-166` | |
| 796 | + | |
| 797 | +--- | |
| 798 | + | |
| 799 | +## 6. 特殊处理逻辑 | |
| 800 | + | |
| 801 | +### 6.1 删除检测 | |
| 802 | + | |
| 803 | +系统支持两种删除方式: | |
| 804 | + | |
| 805 | +1. **显式删除**:通过 `delete_spu_ids` 参数显式指定要删除的 SPU | |
| 806 | +2. **自动检测删除**: | |
| 807 | + - SPU 在数据库中不存在 | |
| 808 | + - SPU 的 `deleted` 字段为 `1` 或 `b'\x01'` | |
| 809 | + | |
| 810 | +**删除处理:** | |
| 811 | + | |
| 812 | +```python | |
| 813 | +# 检查 deleted 字段(可能是 bit 类型) | |
| 814 | +def _is_deleted_value(v): | |
| 815 | + if isinstance(v, bytes): | |
| 816 | + return v == b"\x01" or v == 1 | |
| 817 | + return bool(v) | |
| 818 | + | |
| 819 | +spu_df["_is_deleted"] = spu_df["deleted"].apply(_is_deleted_value) | |
| 820 | +``` | |
| 821 | + | |
| 822 | +**代码位置:** `indexer/incremental_service.py:481-517` | |
| 823 | + | |
| 824 | +### 6.2 数据质量检查 | |
| 825 | + | |
| 826 | +#### 6.2.1 类别映射缺失 | |
| 827 | + | |
| 828 | +如果 `category_path` 中的类目ID在映射中不存在: | |
| 829 | +- 记录错误日志 | |
| 830 | +- 不写入任何类目字段(视为没有类目) | |
| 831 | +- 不阻塞索引流程 | |
| 832 | + | |
| 833 | +#### 6.2.2 标题缺失 | |
| 834 | + | |
| 835 | +如果 SPU 没有标题: | |
| 836 | +- 记录错误日志 | |
| 837 | +- 继续处理(但可能影响搜索效果) | |
| 838 | + | |
| 839 | +#### 6.2.3 SKU 缺失 | |
| 840 | + | |
| 841 | +如果 SPU 没有 SKU: | |
| 842 | +- 记录警告日志 | |
| 843 | +- 继续处理(但价格、库存等字段可能为空) | |
| 844 | + | |
| 845 | +### 6.3 性能优化 | |
| 846 | + | |
| 847 | +1. **批量查询**:使用 `IN` 子句批量查询,减少数据库往返 | |
| 848 | +2. **缓存分类映射**:在服务初始化时预加载,避免重复查询 | |
| 849 | +3. **缓存 Transformer**:按租户缓存 Transformer 实例,避免重复初始化 | |
| 850 | +4. **批量生成 Embedding**:收集所有标题后批量生成,利用批处理性能 | |
| 851 | +5. **批量写入 ES**:使用 `BulkIndexer` 批量写入,提高写入效率 | |
| 852 | + | |
| 853 | +--- | |
| 854 | + | |
| 855 | +## 7. ES 文档完整示例 | |
| 856 | + | |
| 857 | +```json | |
| 858 | +{ | |
| 859 | + "tenant_id": "1", | |
| 860 | + "spu_id": "12345", | |
| 861 | + "title_zh": "iPhone 15 Pro Max", | |
| 862 | + "title_en": "iPhone 15 Pro Max", | |
| 863 | + "brief_zh": "最新款 iPhone", | |
| 864 | + "brief_en": "Latest iPhone", | |
| 865 | + "description_zh": "详细描述...", | |
| 866 | + "description_en": "Detailed description...", | |
| 867 | + "vendor_zh": "Apple", | |
| 868 | + "vendor_en": "Apple", | |
| 869 | + "tags": ["手机", "智能手机", "Apple"], | |
| 870 | + "category_path_zh": "电子产品/手机/iPhone", | |
| 871 | + "category1_name": "电子产品", | |
| 872 | + "category2_name": "手机", | |
| 873 | + "category3_name": "iPhone", | |
| 874 | + "category_id": "3", | |
| 875 | + "category_level": 3, | |
| 876 | + "option1_name": "颜色", | |
| 877 | + "option2_name": "存储容量", | |
| 878 | + "option3_name": null, | |
| 879 | + "option1_values": ["深空黑色", "原色钛金属", "白色钛金属"], | |
| 880 | + "option2_values": ["256GB", "512GB", "1TB"], | |
| 881 | + "option3_values": [], | |
| 882 | + "image_url": "https://example.com/image.jpg", | |
| 883 | + "sales": 1000, | |
| 884 | + "min_price": 8999.0, | |
| 885 | + "max_price": 12999.0, | |
| 886 | + "compare_at_price": 12999.0, | |
| 887 | + "sku_prices": [8999.0, 10999.0, 12999.0], | |
| 888 | + "sku_weights": [221, 221, 221], | |
| 889 | + "sku_weight_units": ["g"], | |
| 890 | + "total_inventory": 500, | |
| 891 | + "create_time": "2024-01-01T00:00:00", | |
| 892 | + "update_time": "2024-01-15T10:30:00", | |
| 893 | + "title_embedding": [0.123, 0.456, ...], // 1024维向量 | |
| 894 | + "skus": [ | |
| 895 | + { | |
| 896 | + "sku_id": "1001", | |
| 897 | + "price": 8999.0, | |
| 898 | + "compare_at_price": 9999.0, | |
| 899 | + "sku_code": "IP15PM-256-BLK", | |
| 900 | + "stock": 100, | |
| 901 | + "weight": 221.0, | |
| 902 | + "weight_unit": "g", | |
| 903 | + "option1_value": "深空黑色", | |
| 904 | + "option2_value": "256GB", | |
| 905 | + "option3_value": null, | |
| 906 | + "image_src": "https://example.com/sku1.jpg" | |
| 907 | + }, | |
| 908 | + { | |
| 909 | + "sku_id": "1002", | |
| 910 | + "price": 10999.0, | |
| 911 | + "compare_at_price": 11999.0, | |
| 912 | + "sku_code": "IP15PM-512-BLK", | |
| 913 | + "stock": 200, | |
| 914 | + "weight": 221.0, | |
| 915 | + "weight_unit": "g", | |
| 916 | + "option1_value": "深空黑色", | |
| 917 | + "option2_value": "512GB", | |
| 918 | + "option3_value": null, | |
| 919 | + "image_src": "https://example.com/sku2.jpg" | |
| 920 | + } | |
| 921 | + ], | |
| 922 | + "specifications": [ | |
| 923 | + {"sku_id": "1001", "name": "颜色", "value": "深空黑色"}, | |
| 924 | + {"sku_id": "1001", "name": "存储容量", "value": "256GB"}, | |
| 925 | + {"sku_id": "1002", "name": "颜色", "value": "深空黑色"}, | |
| 926 | + {"sku_id": "1002", "name": "存储容量", "value": "512GB"} | |
| 927 | + ] | |
| 928 | +} | |
| 929 | +``` | |
| 930 | + | |
| 931 | +--- | |
| 932 | + | |
| 933 | +## 8. 总结 | |
| 934 | + | |
| 935 | +### 8.1 关键要点 | |
| 936 | + | |
| 937 | +1. **数据源**:三个主要表(SPU、SKU、Option) | |
| 938 | +2. **批量查询**:使用 `IN` 子句提高查询效率 | |
| 939 | +3. **类别映射**:通过预加载的映射表将 ID 转换为名称 | |
| 940 | +4. **多款式处理**:通过嵌套结构和扁平化字段支持复杂的 SKU 查询 | |
| 941 | +5. **多语言支持**:自动翻译文本字段,支持中英文双语 | |
| 942 | +6. **向量搜索**:批量生成 embedding,支持语义搜索 | |
| 943 | + | |
| 944 | +### 8.2 复杂字段处理总结 | |
| 945 | + | |
| 946 | +| 字段类型 | 处理方式 | 关键逻辑 | | |
| 947 | +|---------|---------|---------| | |
| 948 | +| **类别** | ID映射 + 路径解析 | 从 `category_path` 解析ID,通过映射表转换为名称,构建多级分类 | | |
| 949 | +| **SKU** | 嵌套数组 + 聚合计算 | 将每个 SKU 转换为嵌套对象,同时计算价格范围、总库存等聚合值 | | |
| 950 | +| **选项** | 名称 + 值分离 | 从 Option 表获取名称,从 SKU 表提取值,分别存储 | | |
| 951 | +| **规格** | 结构化数组 | 将 SKU 的选项值转换为 `{name, value, sku_id}` 结构,支持过滤和分面 | | |
| 952 | + | |
| 953 | +--- | |
| 954 | + | |
| 955 | +## 附录:相关代码文件 | |
| 956 | + | |
| 957 | +- `indexer/incremental_service.py`: 增量索引服务,数据获取逻辑 | |
| 958 | +- `indexer/document_transformer.py`: 文档转换器,字段填充逻辑 | |
| 959 | +- `indexer/indexing_utils.py`: 工具函数,分类映射加载和 Transformer 创建 | |
| 960 | +- `indexer/bulk_indexer.py`: 批量写入器,ES 写入逻辑 | |
| 961 | + | ... | ... |
| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | +# IMG SEO 插件分析 | |
| 2 | + | |
| 3 | +## 1. 解决的具体问题 | |
| 4 | +- **图片搜索排名低**:通过优化 Alt 标签,让商品图片在 Google 图片搜索中获得更高排名。 | |
| 5 | +- **运营效率低下**:手动为大量商品图片设置 Alt 标签非常耗时,该工具提供批量处理能力。 | |
| 6 | +- **网站可访问性(Accessibility)**:为视障人士或图片加载失败时提供文字描述,符合 Web 标准。 | |
| 7 | +- **SEO 规范缺失**:商家不知道如何编写符合 SEO 最佳实践的 Alt 文本,工具提供内置推荐配置。 | |
| 8 | + | |
| 9 | +## 2. 技术方案/功能点 | |
| 10 | +- **智能 Alt 标签模板**:支持基于产品标题、集合名称等变量自动生成优化描述。 | |
| 11 | +- **三大场景覆盖**:一键覆盖商品图、专辑封面、博客配图。 | |
| 12 | +- **批量处理引擎**:支持全站图片的一键批量优化。 | |
| 13 | +- **内置最佳实践**:提供一键采用的推荐配置,确保符合搜索引擎规范。 | |
| 14 | +- **无障碍支持**:生成的 Alt 文本提升了页面的无障碍访问体验。 | |
| 15 | + | |
| 16 | +## 3. 市场需求 | |
| 17 | +- 视觉驱动型电商:如服装、家居等行业,图片搜索是重要的流量来源。 | |
| 18 | +- 自动化运维:减少重复性劳动,确保新上传的图片自动符合 SEO 要求。 | |
| 19 | +- 合规性需求:提升网站的无障碍访问水平。 | |
| 20 | + | |
| 21 | +## 4. 详细用法与案例 | |
| 22 | +### 用法步骤: | |
| 23 | +1. **安装**:在应用商店安装“图片 SEO”插件。 | |
| 24 | +2. **选择场景**:选择要优化的场景(商品、专辑或博客)。 | |
| 25 | +3. **设置模板**:使用变量(如 `{product_title}`)或推荐配置设置 Alt 规则。 | |
| 26 | +4. **执行批量优化**:点击一键优化,系统将自动处理存量图片。 | |
| 27 | + | |
| 28 | +### 使用案例: | |
| 29 | +**案例:家居装饰独立站的图片流量获取** | |
| 30 | +- **背景**:某家居站拥有大量精美的产品实拍图,但由于没有 Alt 标签,这些图片无法在 Google 图片搜索中被搜到。 | |
| 31 | +- **配置方案**: | |
| 32 | + - **模板设置**:`{product_title} - Modern Home Decor | {shop_name}`。 | |
| 33 | + - **操作**:对全站 500 个商品的 3000 张图片执行了一键批量优化。 | |
| 34 | +- **效果**:一个月后,来自 Google Images 的自然流量增长了 45%,且部分长尾关键词(如“modern minimalist vase”)的图片搜索结果排到了前三名。 | ... | ... |
| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | +# SEO Optimizer 插件分析 | |
| 2 | + | |
| 3 | +## 1. 解决的具体问题 | |
| 4 | +- **SEO 诊断与评估**:帮助商家识别店铺中存在的 SEO 缺陷。 | |
| 5 | +- **全站 Meta 标签覆盖**:解决手动为成百上千个商品、专辑、博客设置 Meta 标签的低效问题。 | |
| 6 | +- **图片搜索流量缺失**:通过自动/批量设置图片 Alt 标签,提升图片在 Google Images 的排名。 | |
| 7 | +- **死链影响权重**:修复 404 无效链接,通过重定向保留页面权重。 | |
| 8 | +- **搜索引擎收录慢**:自动生成并提交 Sitemap,加速 Google 索引。 | |
| 9 | +- **高级 SEO 控制**:提供 Robots.txt 管理,满足个性化爬取需求。 | |
| 10 | + | |
| 11 | +## 2. 技术方案/功能点 | |
| 12 | +- **自动化 Meta 标签**:支持首页、商品、专辑、博客等页面的批量 Meta 设置,支持变量引用。 | |
| 13 | +- **图片 SEO 规则引擎**:基于自定义规则(如商品名+关键词)自动为新图片生成 Alt 文本。 | |
| 14 | +- **URL 重定向工具**:支持路径级和完整 URL 级的 301/302 重定向设置。 | |
| 15 | +- **Sitemap 自动更新**:每日凌晨自动更新站点地图,并提供与 Google Search Console 的集成接口。 | |
| 16 | +- **JSON-LD 结构化数据**:自动添加 Google Snippets(面包屑、商品信息、网站链接),提升搜索结果展示效果。 | |
| 17 | +- **Robots.txt 编辑器**:支持预览、编辑和一键屏蔽爬虫功能。 | |
| 18 | + | |
| 19 | +## 3. 市场需求 | |
| 20 | +- 规模化运营需求:商品量大的商家需要自动化工具。 | |
| 21 | +- 技术进阶需求:需要 Robots.txt 和结构化数据等高级 SEO 手段。 | |
| 22 | +- 流量监控需求:集成数据看板,直观查看自然流量转化。 | |
| 23 | + | |
| 24 | +## 4. 详细用法与案例 | |
| 25 | +### 用法步骤: | |
| 26 | +1. **Meta 标签设置**:在插件后台点击“Meta标签”,切换不同页面类型,输入模板并保存。 | |
| 27 | +2. **图片 Alt 设置**:在“图片SEO”模块设置生成规则,开启自动应用。 | |
| 28 | +3. **死链修复**:在“无效链接”模块输入旧路径和新目标 URL。 | |
| 29 | +4. **Sitemap 提交**:复制自动生成的 Sitemap 地址,前往 Google Search Console 提交。 | |
| 30 | + | |
| 31 | +### 使用案例: | |
| 32 | +**案例:大型 3C 电子产品站的自动化 SEO 维护** | |
| 33 | +- **背景**:某站有 2000+ SKU,手动设置 SEO 极其耗时,且经常有下架商品导致死链。 | |
| 34 | +- **配置方案**: | |
| 35 | + - **Meta 模版**:商品页 Meta 标题设为 `{product_title} - Buy Online at {shop_name}`。 | |
| 36 | + - **图片 Alt**:规则设为 `{product_title} - {shop_name} Electronics`。 | |
| 37 | + - **死链处理**:将所有已下架的旧款手机页面重定向到对应的最新款分类页。 | |
| 38 | +- **效果**:全站 SEO 覆盖率从 10% 提升到 100%,图片搜索带来的流量增长了 30%,且避免了因死链导致的 Google 惩罚。 | ... | ... |
| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | +# 智能商品搜索插件分析 | |
| 2 | + | |
| 3 | +## 1. 解决的具体问题 | |
| 4 | +- **搜索效率低下**:帮助顾客快速定位目标商品。 | |
| 5 | +- **搜索结果不精准**:通过自定义排序规则和筛选条件优化结果。 | |
| 6 | +- **缺乏引导**:通过搜索热词和历史记录引导用户搜索。 | |
| 7 | +- **转化率提升**:智能关联推荐与搜索词相关的商品,增加曝光和转化。 | |
| 8 | + | |
| 9 | +## 2. 技术方案/功能点 | |
| 10 | +- **多维度筛选器配置**: | |
| 11 | + - **自动拉取数据**:厂商、价格、库存。 | |
| 12 | + - **手动配置/选择**:款式(Size/Color等)、标签、元字段(外形、尺寸、体积、重量、单行文本)。 | |
| 13 | +- **自定义排序条件**: | |
| 14 | + - 默认首位:Recommend(推荐)。 | |
| 15 | + - 可选维度:最新上架、价格高低、销量高低、加购最多、浏览最多、名称 A-Z/Z-A。 | |
| 16 | +- **搜索热词与引导**: | |
| 17 | + - 手动设置特定搜索热词。 | |
| 18 | + - 自动推荐高频搜索词。 | |
| 19 | + - 展示用户历史搜索记录。 | |
| 20 | +- **主题深度集成**:仅支持 Nova2023 系列(Night, Sweet, Morning, Bamboo, Moon)3.3.0 版本以上。 | |
| 21 | + | |
| 22 | +## 4. 详细用法与案例 | |
| 23 | +### 用法步骤: | |
| 24 | +1. **安装**:通过应用商店或主题装修页面快捷安装。 | |
| 25 | +2. **配置筛选器**:点击“添加筛选维度”,选择类型(如标签或元字段),并设置标题。 | |
| 26 | +3. **配置排序**:在插件后台调整排序维度的顺序。 | |
| 27 | +4. **应用**:开启“应用到店铺”开关。 | |
| 28 | + | |
| 29 | +### 使用案例: | |
| 30 | +**案例:时尚服装独立站优化搜索体验** | |
| 31 | +- **背景**:某服装站商品繁多,用户搜索“连衣裙”后结果太多,难以抉择。 | |
| 32 | +- **配置方案**: | |
| 33 | + - **筛选器**:添加“款式”筛选(尺码、颜色)、“价格”区间筛选、“标签”筛选(如“2024夏季新品”)。 | |
| 34 | + - **排序**:将“销量高低”和“加购最多”排在靠前位置,帮助用户参考他人购买偏好。 | |
| 35 | + - **热词**:设置“法式复古”、“显瘦”为热词,引导用户点击。 | |
| 36 | +- **效果**:用户通过筛选快速找到符合自己尺码和预算的连衣裙,提升了搜索到下单的转化率。 | |
| 37 | + | |
| 38 | +## 3. 市场需求 | |
| 39 | +- 提升站内搜索体验,减少用户流失。 | |
| 40 | +- 商家希望通过搜索数据引导用户购买特定商品(通过热词)。 | |
| 41 | +- 自动化推荐减少人工配置成本。 | ... | ... |
| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | +# Website SEO 插件分析 | |
| 2 | + | |
| 3 | +## 1. 解决的具体问题 | |
| 4 | +- **搜索排名靠后**:通过优化 TDK(Title, Description, Keywords)提升在 Google 等搜索引擎的排名。 | |
| 5 | +- **自然流量匮乏**:通过 SEO 吸引精准客户,减少对付费广告的依赖。 | |
| 6 | +- **SEO 门槛高**:为新手商家提供简化的设置流程,快速上手 SEO 基础配置。 | |
| 7 | + | |
| 8 | +## 2. 技术方案/功能点 | |
| 9 | +- **主页 TDK 管理**: | |
| 10 | + - **SEO 标题**:自定义主页在搜索结果中显示的标题。 | |
| 11 | + - **SEO 描述**:添加店铺整体描述和关键词,增强相关性。 | |
| 12 | + - **SEO 关键词**:添加多个核心关键字,扩大搜索覆盖面。 | |
| 13 | +- **索引优化**:通过规范化的元标签设置,增强搜索引擎的爬取和索引效率。 | |
| 14 | + | |
| 15 | +## 3. 市场需求 | |
| 16 | +- 商家需要低成本获取流量的手段。 | |
| 17 | +- 品牌化建设需求,确保搜索品牌名时能展示正确的描述。 | |
| 18 | +- 自动化/引导式 SEO 配置,降低技术理解成本。 | |
| 19 | + | |
| 20 | +## 4. 详细用法与案例 | |
| 21 | +### 用法步骤: | |
| 22 | +1. **安装**:在应用商店搜索“网站SEO”并安装。 | |
| 23 | +2. **配置主页 SEO**:进入插件,填写“主页SEO标题”、“主页SEO描述”和“SEO关键词”。 | |
| 24 | +3. **保存**:点击保存,等待搜索引擎抓取更新。 | |
| 25 | + | |
| 26 | +### 使用案例: | |
| 27 | +**案例:新兴宠物用品独立站的品牌曝光优化** | |
| 28 | +- **背景**:一家新开的宠物用品店“Pawsome Friends”,搜索品牌名时结果杂乱,且没有吸引人的描述。 | |
| 29 | +- **配置方案**: | |
| 30 | + - **标题**:Pawsome Friends | Premium Pet Supplies & Eco-friendly Toys | |
| 31 | + - **描述**:Shop at Pawsome Friends for high-quality, eco-friendly pet toys and organic treats. Free shipping on orders over $50. Your pet's happiness is our priority. | |
| 32 | + - **关键词**:pet supplies, eco-friendly dog toys, organic cat treats, Pawsome Friends. | |
| 33 | +- **效果**:两周后,Google 搜索结果显示了整洁的品牌标题和描述,点击率(CTR)明显提升,品牌专业度得到认可。 | ... | ... |
docs/blog/店匠(Shoplazza)插件功能分析报告:搜索与推荐方向的市场需求与技术方案提炼.md
0 → 100644
| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | +# 店匠(Shoplazza)插件功能分析报告:搜索与推荐方向的市场需求与技术方案提炼 | |
| 2 | + | |
| 3 | +**作者:** Manus AI | |
| 4 | +**日期:** 2025年12月30日 | |
| 5 | + | |
| 6 | +## 引言 | |
| 7 | + | |
| 8 | +本报告旨在通过对店匠(Shoplazza)生态中四款核心插件——**智能商品搜索**、**网站 SEO**、**SEO 优化工具**和**图片 SEO**——的功能、技术方案及市场反馈进行深入分析,为贵公司的电商独立站 SaaS 系统在**搜索**和**推荐**领域提供市场需求收集和技术方向的参考。通过研究这些成熟工具,我们可以提炼出商家在站内搜索体验、站外搜索引擎优化(SEO)方面的核心痛点和解决方案。 | |
| 9 | + | |
| 10 | +## 插件功能概览与核心价值 | |
| 11 | + | |
| 12 | +这四款插件分别从站内搜索体验和站外自然流量获取两个维度,解决了电商商家在运营中的关键问题。下表总结了每个工具的核心价值和目标。 | |
| 13 | + | |
| 14 | +| 插件名称 | 核心解决问题 | 技术方案核心 | 市场需求定位 | | |
| 15 | +| :--- | :--- | :--- | :--- | | |
| 16 | +| **智能商品搜索** | 站内搜索效率低下、结果不精准 | 多维度筛选器、自定义排序规则、搜索热词引导 | 提升站内转化率、优化用户体验 | | |
| 17 | +| **网站 SEO** | 网站主页 SEO 基础配置缺失 | 主页 TDK(标题、描述、关键词)管理 | 品牌曝光、低成本自然流量获取 | | |
| 18 | +| **SEO 优化工具** | 全站 SEO 维护复杂、效率低下 | 批量 Meta 标签、Sitemap 自动化、URL 重定向、结构化数据 | 规模化运营、高级 SEO 控制、死链修复 | | |
| 19 | +| **图片 SEO** | 图片搜索流量缺失、Alt 标签配置耗时 | 智能 Alt 标签模板、批量优化引擎 | 视觉电商流量获取、无障碍访问合规 | | |
| 20 | + | |
| 21 | +## 详细分析与技术方案提炼 | |
| 22 | + | |
| 23 | +### 1. 智能商品搜索插件 | |
| 24 | + | |
| 25 | +#### 解决的具体问题与市场需求 | |
| 26 | + | |
| 27 | +该插件的核心价值在于解决电商平台**站内搜索**的效率和精准度问题 [1]。在商品数量庞大的独立站中,用户往往难以快速找到目标商品,导致跳出率升高。商家迫切需要工具来**优化搜索结果的排序和筛选**,并通过**搜索热词**等方式引导用户,最终目标是提升搜索到购买的转化率。 | |
| 28 | + | |
| 29 | +#### 技术方案与 SaaS 系统方向 | |
| 30 | + | |
| 31 | +该插件的技术方案体现了对搜索结果的精细化控制: | |
| 32 | + | |
| 33 | +* **多维度筛选器配置**:技术上需要支持从商品数据中**自动拉取**(如价格、库存)和**手动配置**(如款式、标签、元字段)两种类型的筛选维度。这要求 SaaS 系统具备灵活的**商品属性元数据管理**能力。 | |
| 34 | +* **自定义排序引擎**:除了默认的推荐排序,还允许商家根据**业务需求**(如销量、加购数、上新时间)自定义排序权重。这表明 SaaS 系统应提供一个**可配置的搜索结果排序算法接口**,允许商家通过后台配置影响搜索结果的展示逻辑。 | |
| 35 | +* **搜索引导机制**:通过**搜索热词**和**历史记录**实现用户搜索意图的捕捉和引导。这要求系统具备**搜索日志分析**能力,能够识别高频搜索词并将其作为推荐热词。 | |
| 36 | + | |
| 37 | +#### 使用案例:时尚服装独立站优化搜索体验 | |
| 38 | + | |
| 39 | +某服装站通过配置**款式**(尺码、颜色)和**价格**筛选器,并将**销量高低**和**加购最多**设置为靠前排序规则,显著提升了用户在搜索“连衣裙”后找到目标商品的效率,从而提高了转化率。 | |
| 40 | + | |
| 41 | +### 2. 网站 SEO 插件 | |
| 42 | + | |
| 43 | +#### 解决的具体问题与市场需求 | |
| 44 | + | |
| 45 | +网站 SEO 插件专注于解决**网站主页**的 SEO 基础配置问题 [2]。对于新手商家而言,如何设置**标题(Title)**、**描述(Description)**和**关键词(Keywords)**是进入 SEO 领域的第一步。市场需求集中在**简化操作**、**快速上手**,以低成本方式获取搜索引擎的**品牌曝光**和**自然流量**。 | |
| 46 | + | |
| 47 | +#### 技术方案与 SaaS 系统方向 | |
| 48 | + | |
| 49 | +其技术方案相对基础,但至关重要: | |
| 50 | + | |
| 51 | +* **TDK 集中管理**:提供一个统一的界面来设置网站主页的元标签。 | |
| 52 | +* **搜索引擎预览**:在后台提供 Google 搜索结果的**实时预览**功能,帮助商家优化文案以提高点击率(CTR)。 | |
| 53 | + | |
| 54 | +对于 SaaS 系统而言,这是**基础功能**,应内置于核心建站模块中,确保每个新站点都能轻松完成基础 SEO 配置。 | |
| 55 | + | |
| 56 | +#### 使用案例:新兴宠物用品独立站的品牌曝光优化 | |
| 57 | + | |
| 58 | +一家新开的宠物用品店通过设置清晰的品牌标题和吸引人的描述(如包含“Premium Pet Supplies”和“Free shipping”),在 Google 搜索结果中展示了专业的品牌形象,有效提升了点击率。 | |
| 59 | + | |
| 60 | +### 3. SEO 优化工具插件 | |
| 61 | + | |
| 62 | +#### 解决的具体问题与市场需求 | |
| 63 | + | |
| 64 | +SEO 优化工具是针对**规模化运营**和**高级 SEO 维护**的解决方案 [3]。它解决了商家在商品数量增加后,手动维护全站 SEO 标签、处理死链、提交网站地图等一系列复杂且耗时的任务。市场需求是**自动化**、**高级控制**和**合规性**。 | |
| 65 | + | |
| 66 | +#### 技术方案与 SaaS 系统方向 | |
| 67 | + | |
| 68 | +该插件的技术方案涉及多个高级 SEO 模块: | |
| 69 | + | |
| 70 | +* **批量 Meta 标签与规则引擎**:通过**变量引用**(如 `{product_title}`)实现全站商品、专辑、博客等页面的 Meta 标签**自动化生成**。这要求 SaaS 系统提供强大的**模板引擎**和**数据变量接口**。 | |
| 71 | +* **URL 重定向管理**:提供路径级和完整 URL 级的 **301/302 重定向**功能,用于修复死链和保留页面权重。这是维护网站健康度的关键技术。 | |
| 72 | +* **Sitemap 自动化与集成**:**实时或定时**生成并更新网站地图,并提供与 **Google Search Console** 的**集成接口**,实现一键提交。 | |
| 73 | +* **结构化数据(JSON-LD)**:自动生成并嵌入 **Google Snippets**(如面包屑、商品信息),以增强搜索结果的丰富度和可信度。 | |
| 74 | +* **Robots.txt 编辑器**:提供对爬虫访问权限的**高级控制**,满足个性化 SEO 需求。 | |
| 75 | + | |
| 76 | +对于 SaaS 系统,这表明**自动化 SEO 规则引擎**和**外部工具集成**(如 Google Search Console)是提升产品专业度的重要方向。 | |
| 77 | + | |
| 78 | +#### 使用案例:大型 3C 电子产品站的自动化 SEO 维护 | |
| 79 | + | |
| 80 | +一个拥有数千 SKU 的电子产品站,通过设置 Meta 标签模板和 Alt 标签规则,实现了全站 SEO 的自动化覆盖。同时,利用重定向功能将所有旧款下架商品页面导向最新款分类页,成功避免了因死链导致的 SEO 惩罚。 | |
| 81 | + | |
| 82 | +### 4. 图片 SEO 插件 | |
| 83 | + | |
| 84 | +#### 解决的具体问题与市场需求 | |
| 85 | + | |
| 86 | +图片 SEO 插件专注于解决**图片搜索流量**的获取问题 [4]。对于以视觉为驱动的电商(如服装、家居),图片是重要的流量入口。该插件通过**批量、智能地设置图片 Alt 标签**,解决了手动操作的低效性,并提升了网站的**无障碍访问**水平。 | |
| 87 | + | |
| 88 | +#### 技术方案与 SaaS 系统方向 | |
| 89 | + | |
| 90 | +该插件的技术方案是 SEO 规则引擎在图片领域的应用: | |
| 91 | + | |
| 92 | +* **智能 Alt 标签模板**:与 SEO 优化工具类似,使用**变量**(如产品标题、集合名称)来构造符合 SEO 规范的 Alt 文本。 | |
| 93 | +* **批量处理能力**:核心技术在于能够**高效地遍历**全站图片资源,并**批量写入** Alt 属性,同时支持对新上传图片的**自动应用**。 | |
| 94 | +* **场景覆盖**:支持商品图、专辑封面、博客配图等**多场景**的 Alt 标签优化。 | |
| 95 | + | |
| 96 | +对于 SaaS 系统,这强调了**媒体资源管理**模块中应内置**智能 Alt 文本生成**功能,可以考虑集成 AI 能力,根据图片内容自动生成描述性 Alt 文本,进一步提升自动化水平。 | |
| 97 | + | |
| 98 | +## 总结与对 SaaS 系统的方向建议 | |
| 99 | + | |
| 100 | +通过对上述插件的分析,我们可以为贵公司的电商独立站 SaaS 系统在**搜索**和**推荐**方向提炼出以下关键市场需求和技术方向: | |
| 101 | + | |
| 102 | +| 领域 | 市场需求提炼 | 技术方案方向 | | |
| 103 | +| :--- | :--- | :--- | | |
| 104 | +| **站内搜索** | **精准度与引导**:用户需要快速找到目标商品,商家需要引导用户购买特定商品。 | **可配置的搜索排序引擎**:允许商家自定义权重(销量、价格、新品等)。<br>**智能搜索引导**:基于搜索日志的**热词推荐**和**联想词**功能。 | | |
| 105 | +| **站外 SEO** | **自动化与规模化**:商品数量多,需要自动化工具进行全站 SEO 维护。 | **全站 SEO 规则模板引擎**:支持变量引用,批量生成 Meta 标签和 Alt 文本。<br>**网站健康度工具**:内置 **URL 重定向**、**Sitemap 自动化**、**Robots.txt 管理**等高级功能。 | | |
| 106 | +| **推荐** | **关联性与转化**:在搜索结果页和商品详情页提供相关推荐,以提升转化。 | **智能关联推荐**:基于搜索词、浏览历史、商品属性的**实时推荐算法**。 | | |
| 107 | + | |
| 108 | +**核心建议:** | |
| 109 | + | |
| 110 | +1. **构建统一的规则引擎**:将 SEO 优化工具和图片 SEO 中的**模板/变量**技术方案整合,形成一个统一的**“元数据自动化规则引擎”**,用于批量管理 TDK、Alt 标签等,大幅提升商家运营效率。 | |
| 111 | +2. **深化站内搜索能力**:将“智能商品搜索”的功能作为核心竞争力,提供**多维度筛选**和**自定义排序**的灵活配置,并利用搜索数据进行**智能推荐**,将搜索功能从简单的匹配升级为**转化工具**。 | |
| 112 | + | |
| 113 | +## 参考文献 | |
| 114 | + | |
| 115 | +[1] 智能商品搜索 | 店匠应用商店: <https://appstore.shoplazza.com/listing/141443/%E6%99%BA%E8%83%BD%E5%95%86%E5%93%81%E6%90%9C%E7%B4%A2?locale=zh-CN&utm_source=qiyu&utm_medium=CS> | |
| 116 | +[2] Website SEO | 店匠应用商店: <https://appstore.shoplazza.com/listing/143/website_seo?locale=zh-CN&utm_source=qiyu&utm_medium=CS> | |
| 117 | +[3] SEO优化工具 | 店匠应用商店: <https://appstore.shoplazza.com/listing/49161/seo-optimizer/?locale=zh-CN&utm_source=qiyu&utm_medium=CS> | |
| 118 | +[4] 图片 SEO | 店匠应用商店: <https://appstore.shoplazza.com/listing/145/img_seo/?locale=zh-CN&utm_source=qiyu&utm_medium=CS> | |
| 119 | +[5] 智能商品搜索|配置筛选项 – Shoplazza 帮助中心: <https://helpcenter.shoplazza.com/hc/zh-cn/articles/28275417748505-%E6%99%BA%E8%83%BD%E5%95%86%E5%93%81%E6%90%9C%E7%B4%A2%E6%8F%92%E4%BB%B6#--0-0> | |
| 120 | +[6] 设置网站SEO – Shoplazza 帮助中心: <https://helpcenter.shoplazza.com/hc/zh-cn/articles/4408566421273-%E8%AE%BE%E7%BD%AE%E7%BD%91%E7%AB%99SEO> | |
| 121 | +[7] SEO优化工具使用说明 – Shoplazza 帮助中心: <https://helpcenter.shoplazza.com/hc/zh-cn/articles/11696305150361-SEO%E4%BC%98%E5%8C%96%E5%B7%A5%E5%85%B7%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E?utm_source=qiyu&utm_medium=CS> | ... | ... |
docs/亚马逊到店匠格式转换分析.md
| ... | ... | @@ -0,0 +1,189 @@ |
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +对比不同租户索引的 mapping 结构 | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import os | |
| 7 | +import sys | |
| 8 | +import json | |
| 9 | +from pathlib import Path | |
| 10 | +from typing import Dict, Any | |
| 11 | + | |
| 12 | +sys.path.insert(0, str(Path(__file__).parent.parent)) | |
| 13 | + | |
| 14 | +from utils.es_client import get_es_client_from_env | |
| 15 | + | |
| 16 | + | |
| 17 | +def get_field_type(mapping_dict: Dict, field_path: str) -> Dict[str, Any]: | |
| 18 | + """递归获取字段的 mapping 信息""" | |
| 19 | + parts = field_path.split('.') | |
| 20 | + current = mapping_dict | |
| 21 | + | |
| 22 | + for part in parts: | |
| 23 | + if isinstance(current, dict): | |
| 24 | + current = current.get(part) | |
| 25 | + if current is None: | |
| 26 | + return None | |
| 27 | + else: | |
| 28 | + return None | |
| 29 | + return current | |
| 30 | + | |
| 31 | + | |
| 32 | +def compare_mappings(mapping1: Dict[str, Any], mapping2: Dict[str, Any], index1_name: str, index2_name: str): | |
| 33 | + """对比两个索引的 mapping""" | |
| 34 | + props1 = mapping1.get('mappings', {}).get('properties', {}) | |
| 35 | + props2 = mapping2.get('mappings', {}).get('properties', {}) | |
| 36 | + | |
| 37 | + all_fields = set(props1.keys()) | set(props2.keys()) | |
| 38 | + | |
| 39 | + print(f"\n{'='*80}") | |
| 40 | + print(f"对比索引映射结构") | |
| 41 | + print(f"{'='*80}") | |
| 42 | + print(f"索引1: {index1_name}") | |
| 43 | + print(f"索引2: {index2_name}") | |
| 44 | + print(f"{'='*80}\n") | |
| 45 | + | |
| 46 | + differences = [] | |
| 47 | + same_fields = [] | |
| 48 | + | |
| 49 | + for field in sorted(all_fields): | |
| 50 | + field1 = props1.get(field) | |
| 51 | + field2 = props2.get(field) | |
| 52 | + | |
| 53 | + if field1 is None: | |
| 54 | + differences.append((field, f"只在 {index2_name} 中存在", field2)) | |
| 55 | + continue | |
| 56 | + if field2 is None: | |
| 57 | + differences.append((field, f"只在 {index1_name} 中存在", field1)) | |
| 58 | + continue | |
| 59 | + | |
| 60 | + type1 = field1.get('type') | |
| 61 | + type2 = field2.get('type') | |
| 62 | + | |
| 63 | + if type1 != type2: | |
| 64 | + differences.append((field, f"类型不同: {index1_name}={type1}, {index2_name}={type2}", (field1, field2))) | |
| 65 | + else: | |
| 66 | + same_fields.append((field, type1)) | |
| 67 | + | |
| 68 | + # 打印相同的字段 | |
| 69 | + print(f"✓ 相同字段 ({len(same_fields)} 个):") | |
| 70 | + for field, field_type in same_fields[:20]: # 只显示前20个 | |
| 71 | + print(f" - {field}: {field_type}") | |
| 72 | + if len(same_fields) > 20: | |
| 73 | + print(f" ... 还有 {len(same_fields) - 20} 个相同字段") | |
| 74 | + | |
| 75 | + # 打印不同的字段 | |
| 76 | + if differences: | |
| 77 | + print(f"\n✗ 不同字段 ({len(differences)} 个):") | |
| 78 | + for field, reason, details in differences: | |
| 79 | + print(f"\n {field}:") | |
| 80 | + print(f" {reason}") | |
| 81 | + if isinstance(details, tuple): | |
| 82 | + print(f" {index1_name}: {json.dumps(details[0], indent=4, ensure_ascii=False)}") | |
| 83 | + print(f" {index2_name}: {json.dumps(details[1], indent=4, ensure_ascii=False)}") | |
| 84 | + else: | |
| 85 | + print(f" 详情: {json.dumps(details, indent=4, ensure_ascii=False)}") | |
| 86 | + else: | |
| 87 | + print(f"\n✓ 所有字段类型一致!") | |
| 88 | + | |
| 89 | + # 特别检查 tags 字段 | |
| 90 | + print(f"\n{'='*80}") | |
| 91 | + print(f"特别检查: tags 字段") | |
| 92 | + print(f"{'='*80}") | |
| 93 | + | |
| 94 | + tags1 = get_field_type(props1, 'tags') | |
| 95 | + tags2 = get_field_type(props2, 'tags') | |
| 96 | + | |
| 97 | + if tags1: | |
| 98 | + print(f"\n{index1_name}.tags:") | |
| 99 | + print(f" 类型: {tags1.get('type')}") | |
| 100 | + print(f" 完整定义: {json.dumps(tags1, indent=2, ensure_ascii=False)}") | |
| 101 | + else: | |
| 102 | + print(f"\n{index1_name}.tags: 不存在") | |
| 103 | + | |
| 104 | + if tags2: | |
| 105 | + print(f"\n{index2_name}.tags:") | |
| 106 | + print(f" 类型: {tags2.get('type')}") | |
| 107 | + print(f" 完整定义: {json.dumps(tags2, indent=2, ensure_ascii=False)}") | |
| 108 | + else: | |
| 109 | + print(f"\n{index2_name}.tags: 不存在") | |
| 110 | + | |
| 111 | + | |
| 112 | +def main(): | |
| 113 | + import argparse | |
| 114 | + | |
| 115 | + parser = argparse.ArgumentParser(description='对比 Elasticsearch 索引的 mapping 结构') | |
| 116 | + parser.add_argument('index1', help='第一个索引名称 (例如: search_products_tenant_171)') | |
| 117 | + parser.add_argument('index2', nargs='?', help='第二个索引名称 (例如: search_products_tenant_162)') | |
| 118 | + parser.add_argument('--list', action='store_true', help='列出所有以 index1 为前缀的索引') | |
| 119 | + | |
| 120 | + args = parser.parse_args() | |
| 121 | + | |
| 122 | + # 连接 ES | |
| 123 | + try: | |
| 124 | + es_client = get_es_client_from_env() | |
| 125 | + if not es_client.ping(): | |
| 126 | + print("✗ 无法连接到 Elasticsearch") | |
| 127 | + return 1 | |
| 128 | + print("✓ Elasticsearch 连接成功\n") | |
| 129 | + except Exception as e: | |
| 130 | + print(f"✗ 连接 Elasticsearch 失败: {e}") | |
| 131 | + return 1 | |
| 132 | + | |
| 133 | + # 如果指定了 --list,列出所有匹配的索引 | |
| 134 | + if args.list or not args.index2: | |
| 135 | + try: | |
| 136 | + # 使用 cat API 列出所有索引 | |
| 137 | + indices = es_client.client.cat.indices(format='json') | |
| 138 | + matching_indices = [idx['index'] for idx in indices if idx['index'].startswith(args.index1)] | |
| 139 | + | |
| 140 | + if matching_indices: | |
| 141 | + print(f"找到 {len(matching_indices)} 个匹配的索引:") | |
| 142 | + for idx in sorted(matching_indices): | |
| 143 | + print(f" - {idx}") | |
| 144 | + return 0 | |
| 145 | + else: | |
| 146 | + print(f"未找到以 '{args.index1}' 开头的索引") | |
| 147 | + return 1 | |
| 148 | + except Exception as e: | |
| 149 | + print(f"✗ 列出索引失败: {e}") | |
| 150 | + return 1 | |
| 151 | + | |
| 152 | + # 获取两个索引的 mapping | |
| 153 | + index1 = args.index1 | |
| 154 | + index2 = args.index2 | |
| 155 | + | |
| 156 | + print(f"正在获取索引映射...") | |
| 157 | + print(f" 索引1: {index1}") | |
| 158 | + print(f" 索引2: {index2}\n") | |
| 159 | + | |
| 160 | + # 检查索引是否存在 | |
| 161 | + if not es_client.index_exists(index1): | |
| 162 | + print(f"✗ 索引 '{index1}' 不存在") | |
| 163 | + return 1 | |
| 164 | + | |
| 165 | + if not es_client.index_exists(index2): | |
| 166 | + print(f"✗ 索引 '{index2}' 不存在") | |
| 167 | + return 1 | |
| 168 | + | |
| 169 | + # 获取 mapping | |
| 170 | + mapping1 = es_client.get_mapping(index1) | |
| 171 | + mapping2 = es_client.get_mapping(index2) | |
| 172 | + | |
| 173 | + if not mapping1 or index1 not in mapping1: | |
| 174 | + print(f"✗ 无法获取索引 '{index1}' 的映射") | |
| 175 | + return 1 | |
| 176 | + | |
| 177 | + if not mapping2 or index2 not in mapping2: | |
| 178 | + print(f"✗ 无法获取索引 '{index2}' 的映射") | |
| 179 | + return 1 | |
| 180 | + | |
| 181 | + # 对比 mapping | |
| 182 | + compare_mappings(mapping1[index1], mapping2[index2], index1, index2) | |
| 183 | + | |
| 184 | + return 0 | |
| 185 | + | |
| 186 | + | |
| 187 | +if __name__ == '__main__': | |
| 188 | + sys.exit(main()) | |
| 189 | + | ... | ... |
scripts/test_cnclip_service.sh renamed to scripts/test_cnclip_service.py
| 1 | -#!/bin/bash | |
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +CN-CLIP 服务测试脚本 | |
| 2 | 4 | |
| 3 | -############################################################################### | |
| 4 | -# CN-CLIP 服务测试脚本 | |
| 5 | -# | |
| 6 | -# 用途: | |
| 7 | -# 测试 CN-CLIP 服务的文本和图像编码功能(使用 gRPC 协议) | |
| 8 | -# | |
| 9 | -# 使用方法: | |
| 10 | -# ./scripts/test_cnclip_service.sh [PORT] | |
| 11 | -# | |
| 12 | -# 参数: | |
| 13 | -# PORT: 服务端口(默认:51000) | |
| 14 | -# | |
| 15 | -############################################################################### | |
| 5 | +用途: | |
| 6 | + 测试 CN-CLIP 服务的文本和图像编码功能(使用 gRPC 协议) | |
| 16 | 7 | |
| 17 | -set -e | |
| 8 | +使用方法: | |
| 9 | + python scripts/test_cnclip_service.py [PORT] | |
| 18 | 10 | |
| 19 | -# 颜色定义 | |
| 20 | -RED='\033[0;31m' | |
| 21 | -GREEN='\033[0;32m' | |
| 22 | -YELLOW='\033[1;33m' | |
| 23 | -BLUE='\033[0;34m' | |
| 24 | -NC='\033[0m' # No Color | |
| 11 | +参数: | |
| 12 | + PORT: 服务端口(默认:51000) | |
| 13 | +""" | |
| 25 | 14 | |
| 26 | -# 默认端口 | |
| 27 | -PORT=${1:-51000} | |
| 28 | -GRPC_URL="grpc://localhost:${PORT}" | |
| 29 | - | |
| 30 | -echo -e "${BLUE}========================================${NC}" | |
| 31 | -echo -e "${BLUE}CN-CLIP 服务测试${NC}" | |
| 32 | -echo -e "${BLUE}========================================${NC}" | |
| 33 | -echo "" | |
| 34 | -echo -e "服务地址: ${GRPC_URL} (gRPC 协议)" | |
| 35 | -echo "" | |
| 36 | - | |
| 37 | -# 检查 clip_client 是否安装 | |
| 38 | -if ! python3 -c "from clip_client import Client" 2>/dev/null; then | |
| 39 | - echo -e "${RED}错误: clip_client 未安装${NC}" | |
| 40 | - echo -e "${YELLOW}请运行: pip install clip-client${NC}" | |
| 41 | - exit 1 | |
| 42 | -fi | |
| 43 | - | |
| 44 | -# 使用 Python 客户端测试(因为服务使用 gRPC 协议) | |
| 45 | -python3 << PYTHON_EOF | |
| 46 | 15 | import sys |
| 47 | 16 | import numpy as np |
| 48 | 17 | from clip_client import Client |
| 49 | 18 | |
| 19 | + | |
| 50 | 20 | def test_encoding(client, test_name, inputs): |
| 21 | + """测试编码功能""" | |
| 51 | 22 | print(f"\n{test_name}...") |
| 52 | 23 | try: |
| 53 | 24 | result = client.encode(inputs) |
| ... | ... | @@ -60,8 +31,16 @@ def test_encoding(client, test_name, inputs): |
| 60 | 31 | for i in range(min(len(inputs), result.shape[0])): |
| 61 | 32 | emb = result[i] |
| 62 | 33 | first_20 = emb[:20].tolist() |
| 34 | + | |
| 35 | + # 计算 L2 归一化 | |
| 36 | + norm = np.linalg.norm(emb) | |
| 37 | + normalized_emb = emb / norm if norm > 0 else emb | |
| 38 | + normalized_first_20 = normalized_emb[:20].tolist() | |
| 39 | + | |
| 40 | + print(f" input: {inputs[i]}") | |
| 63 | 41 | print(f" Embedding[{i}] 维度: {len(emb)}") |
| 64 | 42 | print(f" 前20个数字: {first_20}") |
| 43 | + print(f" normalize后的前20个数字: {normalized_first_20}") | |
| 65 | 44 | return True |
| 66 | 45 | else: |
| 67 | 46 | print(f"✗ 失败: 返回类型错误: {type(result)}") |
| ... | ... | @@ -72,60 +51,65 @@ def test_encoding(client, test_name, inputs): |
| 72 | 51 | traceback.print_exc() |
| 73 | 52 | return False |
| 74 | 53 | |
| 75 | -# 测试 | |
| 76 | -port = "${PORT}" | |
| 77 | -client = Client(f'grpc://localhost:{port}') | |
| 78 | - | |
| 79 | -results = [] | |
| 80 | - | |
| 81 | -# 测试1: 文本编码 | |
| 82 | -results.append(test_encoding( | |
| 83 | - client, | |
| 84 | - "测试1: 编码文本", | |
| 85 | - ['这是一个测试文本', '另一个测试文本'] | |
| 86 | -)) | |
| 87 | 54 | |
| 88 | -# 测试2: 图像编码 | |
| 89 | -results.append(test_encoding( | |
| 90 | - client, | |
| 91 | - "测试2: 编码图像(远程 URL)", | |
| 92 | - ['https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg'] | |
| 93 | -)) | |
| 94 | - | |
| 95 | -# 测试3: 混合编码 | |
| 96 | -results.append(test_encoding( | |
| 97 | - client, | |
| 98 | - "测试3: 混合编码(文本和图像)", | |
| 99 | - ['这是一段文本', 'https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg'] | |
| 100 | -)) | |
| 101 | - | |
| 102 | -# 汇总 | |
| 103 | -print("\n" + "="*50) | |
| 104 | -print("测试结果汇总") | |
| 105 | -print("="*50) | |
| 106 | -print(f"总测试数: {len(results)}") | |
| 107 | -print(f"通过: {sum(results)}") | |
| 108 | -print(f"失败: {len(results) - sum(results)}") | |
| 109 | - | |
| 110 | -if all(results): | |
| 111 | - print("\n✓ 所有测试通过!") | |
| 112 | - sys.exit(0) | |
| 113 | -else: | |
| 114 | - print("\n✗ 部分测试失败") | |
| 115 | - sys.exit(1) | |
| 116 | -PYTHON_EOF | |
| 117 | - | |
| 118 | -TEST_RESULT=$? | |
| 55 | +def main(): | |
| 56 | + # 获取端口参数 | |
| 57 | + port = sys.argv[1] if len(sys.argv) > 1 else "51000" | |
| 58 | + grpc_url = f"grpc://localhost:{port}" | |
| 59 | + | |
| 60 | + print("=" * 50) | |
| 61 | + print("CN-CLIP 服务测试") | |
| 62 | + print("=" * 50) | |
| 63 | + print(f"服务地址: {grpc_url} (gRPC 协议)") | |
| 64 | + print() | |
| 65 | + | |
| 66 | + # 创建客户端 | |
| 67 | + try: | |
| 68 | + client = Client(grpc_url) | |
| 69 | + except Exception as e: | |
| 70 | + print(f"✗ 客户端创建失败: {e}") | |
| 71 | + sys.exit(1) | |
| 72 | + | |
| 73 | + # 运行测试 | |
| 74 | + results = [] | |
| 75 | + | |
| 76 | + # 测试1: 文本编码 | |
| 77 | + results.append(test_encoding( | |
| 78 | + client, | |
| 79 | + "测试1: 编码文本", | |
| 80 | + ['这是一个测试文本', '另一个测试文本'] | |
| 81 | + )) | |
| 82 | + | |
| 83 | + # 测试2: 图像编码 | |
| 84 | + results.append(test_encoding( | |
| 85 | + client, | |
| 86 | + "测试2: 编码图像(远程 URL)", | |
| 87 | + ['https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg'] | |
| 88 | + )) | |
| 89 | + | |
| 90 | + # 测试3: 混合编码 | |
| 91 | + results.append(test_encoding( | |
| 92 | + client, | |
| 93 | + "测试3: 混合编码(文本和图像)", | |
| 94 | + ['这是一段文本', 'https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg'] | |
| 95 | + )) | |
| 96 | + | |
| 97 | + # 汇总 | |
| 98 | + print("\n" + "=" * 50) | |
| 99 | + print("测试结果汇总") | |
| 100 | + print("=" * 50) | |
| 101 | + print(f"总测试数: {len(results)}") | |
| 102 | + print(f"通过: {sum(results)}") | |
| 103 | + print(f"失败: {len(results) - sum(results)}") | |
| 104 | + | |
| 105 | + if all(results): | |
| 106 | + print("\n✓ 所有测试通过!") | |
| 107 | + sys.exit(0) | |
| 108 | + else: | |
| 109 | + print("\n✗ 部分测试失败") | |
| 110 | + sys.exit(1) | |
| 111 | + | |
| 112 | + | |
| 113 | +if __name__ == '__main__': | |
| 114 | + main() | |
| 119 | 115 | |
| 120 | -if [ $TEST_RESULT -eq 0 ]; then | |
| 121 | - echo "" | |
| 122 | - echo -e "${GREEN}========================================${NC}" | |
| 123 | - echo -e "${GREEN}✓ 所有测试通过!${NC}" | |
| 124 | - echo -e "${GREEN}========================================${NC}" | |
| 125 | -else | |
| 126 | - echo "" | |
| 127 | - echo -e "${RED}========================================${NC}" | |
| 128 | - echo -e "${RED}✗ 部分测试失败${NC}" | |
| 129 | - echo -e "${RED}========================================${NC}" | |
| 130 | - exit 1 | |
| 131 | -fi | ... | ... |
search/searcher.py
| ... | ... | @@ -275,6 +275,7 @@ class Searcher: |
| 275 | 275 | try: |
| 276 | 276 | # Generate tenant-specific index name |
| 277 | 277 | index_name = get_tenant_index_name(tenant_id) |
| 278 | + index_name = "search_products" | |
| 278 | 279 | |
| 279 | 280 | # No longer need to add tenant_id to filters since each tenant has its own index |
| 280 | 281 | ... | ... |