Commit 768ad710662047243eb18877c9a43cc6ec54c60b

Authored by tangwang
1 parent 74cca190

MySQL到ES字段映射说明-业务版.md

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 作为项目导航入口,帮助你在不同阶段定位到更详细的文档。
... ...
api/translator_app.py 0 → 100644
... ... @@ -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 ## 模块说明
... ...
docs/MySQL到ES字段映射说明-业务版.md 0 → 100644
... ... @@ -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 +
... ...
docs/MySQL到ES文档映射说明.md 0 → 100644
... ... @@ -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 +
... ...
docs/blog/img_seo_analysis.md 0 → 100644
... ... @@ -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”)的图片搜索结果排到了前三名。
... ...
docs/blog/seo_optimizer_analysis.md 0 → 100644
... ... @@ -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 惩罚。
... ...
docs/blog/smart_search_analysis.md 0 → 100644
... ... @@ -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 +- 自动化推荐减少人工配置成本。
... ...
docs/blog/website_seo_analysis.md 0 → 100644
... ... @@ -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
... ... @@ -370,3 +370,4 @@ python scripts/amazon_xlsx_to_shoplazza_xlsx.py \
370 370  
371 371  
372 372  
  373 +
... ...
scripts/compare_index_mappings.py 0 → 100644
... ... @@ -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  
... ...