Commit 133771994732aa04c41b87cac7bf9e08f9d7d483

Authored by tangwang
1 parent 42da4b5c

接口优化

.cursor/plans/spu-index-b5a93a00.plan.md 0 → 100644
... ... @@ -0,0 +1,302 @@
  1 +<!-- b5a93a00-49d7-4266-8dbf-3d3f708334ed 23831e56-f1c5-48ab-8ed5-b11125ad0cf9 -->
  2 +# SPU-Level Indexing with Shoplazza API Format & BASE Configuration
  3 +
  4 +## Phase 1: Schema Analysis & Design
  5 +
  6 +### 1.1 Analyze SPU/SKU Fields for Indexing
  7 +
  8 +**SPU Fields** (from `shoplazza_product_spu`):
  9 +
  10 +- **Index + Store**: `id`, `shop_id`, `handle`, `title`, `brief`, `vendor`, `product_type`, `tags`, `category`, `image_src`, `published`, `published_at`
  11 +- **Index only**: `seo_title`, `seo_description`, `seo_keywords`
  12 +- **Store only**: `description` (HTML, no tokenization)
  13 +
  14 +**SKU Fields** (from `shoplazza_product_sku`, as nested array):
  15 +
  16 +- **Index + Store**: `id`, `title`, `sku`, `barcode`, `price`, `compare_at_price`, `option1`, `option2`, `option3`, `inventory_quantity`, `image_src`
  17 +
  18 +**Price Strategy (CONFIRMED - Option B)**:
  19 +
  20 +- Flatten `min_price`, `max_price`, `compare_at_price` at SPU level for fast filtering/sorting
  21 +- Keep full variant prices in nested array for display
  22 +
  23 +### 1.2 Design BASE Schema
  24 +
  25 +**Index**: `search_products` (shared by all tenants)
  26 +
  27 +**Key fields**:
  28 +
  29 +- `tenant_id` (KEYWORD, **REQUIRED**) - always filtered, never optional
  30 +- SPU-level flattened fields
  31 +- `variants` (NESTED array) - SKU data
  32 +- Flattened: `min_price`, `max_price`, `compare_at_price`
  33 +- Multi-language: `title_zh`, `title_en`, `title_ru`, etc.
  34 +- Embeddings: `title_embedding`, `image_embedding`
  35 +
  36 +## Phase 2: BASE Configuration (Universal Standard)
  37 +
  38 +### 2.1 Create BASE Config
  39 +
  40 +**File**: [`config/schema/base/config.yaml`](config/schema/base/config.yaml)
  41 +
  42 +**This is the universal configuration for ALL merchants using Shoplazza tables.**
  43 +
  44 +Key points:
  45 +
  46 +- Index name: `search_products` (shared)
  47 +- Required field: `tenant_id` (always filtered in queries)
  48 +- SPU-level fields with multi-language support
  49 +- Nested variants structure
  50 +- Flattened price fields: `min_price`, `max_price`, `compare_at_price`
  51 +- Function_score configuration
  52 +
  53 +### 2.2 Update Field Types & Mapping
  54 +
  55 +**Files**: [`config/field_types.py`](config/field_types.py), [`indexer/mapping_generator.py`](indexer/mapping_generator.py)
  56 +
  57 +- Add `NESTED` field type
  58 +- Handle nested mapping generation for variants
  59 +- Auto-generate flattened price fields
  60 +
  61 +## Phase 3: Data Ingestion for BASE
  62 +
  63 +### 3.1 SPU-Level Data Transformer
  64 +
  65 +**File**: [`indexer/spu_data_transformer.py`](indexer/spu_data_transformer.py)
  66 +
  67 +Features:
  68 +
  69 +- Load SPU from `shoplazza_product_spu`
  70 +- Join SKU from `shoplazza_product_sku` (grouped by spu_id)
  71 +- Create nested variants array
  72 +- Calculate `min_price`, `max_price`, `compare_at_price`
  73 +- Generate title & image embeddings
  74 +- Inject `tenant_id` from config
  75 +
  76 +### 3.2 Test Data Generator
  77 +
  78 +**File**: [`scripts/generate_shoplazza_test_data.py`](scripts/generate_shoplazza_test_data.py)
  79 +
  80 +Generate 100 SPU records with:
  81 +
  82 +- 10 categories, multiple vendors
  83 +- Multi-language (zh/en/ru)
  84 +- Price range: $5-$500
  85 +- 1-5 variants per SPU (color, size options)
  86 +- Insert into MySQL Shoplazza tables
  87 +
  88 +### 3.3 BASE Ingestion Script
  89 +
  90 +**File**: [`scripts/ingest_base.py`](scripts/ingest_base.py)
  91 +
  92 +- Load from MySQL `shoplazza_product_spu` + `shoplazza_product_sku`
  93 +- Use `SPUDataTransformer`
  94 +- Index into `search_products` with configured `tenant_id`
  95 +
  96 +## Phase 4: Query Updates
  97 +
  98 +### 4.1 Query Builder Enhancements
  99 +
  100 +**File**: [`search/multilang_query_builder.py`](search/multilang_query_builder.py)
  101 +
  102 +- **Auto-inject `tenant_id` filter** (from config, always applied)
  103 +- Support nested queries for variants
  104 +- Use flattened price fields for filters: `min_price`, `max_price`
  105 +
  106 +### 4.2 Searcher Updates
  107 +
  108 +**File**: [`search/searcher.py`](search/searcher.py)
  109 +
  110 +- Enforce `tenant_id` filtering
  111 +- Handle nested inner_hits for variants
  112 +
  113 +## Phase 5: API Response Transformation
  114 +
  115 +### 5.1 Response Transformer
  116 +
  117 +**File**: [`api/response_transformer.py`](api/response_transformer.py)
  118 +
  119 +Transform ES response to Shoplazza format:
  120 +
  121 +- Extract variants from nested array
  122 +- Map fields: `product_id`, `title`, `handle`, `vendor`, `product_type`, `tags`, `price`, `variants`, etc.
  123 +- Calculate `in_stock` from variants
  124 +
  125 +### 5.2 Update API Models
  126 +
  127 +**File**: [`api/models.py`](api/models.py)
  128 +
  129 +New models:
  130 +
  131 +- `VariantOption`, `ProductVariant`, `ProductResult`
  132 +- Updated `SearchResponse` with `results: List[ProductResult]`
  133 +- Add placeholders: `suggestions: List[str] = []`, `related_searches: List[str] = []`
  134 +
  135 +### 5.3 Update Search Routes
  136 +
  137 +**File**: [`api/routes/search.py`](api/routes/search.py)
  138 +
  139 +- Use `ResponseTransformer` to convert ES hits
  140 +- Return new Shoplazza-compatible format
  141 +- **Ensure `tenant_id` is required in request**
  142 +
  143 +## Phase 6: Legacy Migration
  144 +
  145 +### 6.1 Rename Customer1 to Legacy
  146 +
  147 +- Rename [`config/schema/customer1/`](config/schema/customer1/) to [`config/schema/customer1_legacy/`](config/schema/customer1_legacy/)
  148 +- Update config to use old index `search_customer1` (preserve for backward compatibility)
  149 +- Mark as deprecated in comments
  150 +
  151 +### 6.2 Update Scripts for BASE
  152 +
  153 +- [`run.sh`](run.sh): Use BASE config, `search_products` index
  154 +- [`restart.sh`](restart.sh): Use BASE config
  155 +- [`test_all.sh`](test_all.sh): Test BASE config
  156 +- Legacy scripts: Rename with `_legacy` suffix (e.g., `run_legacy.sh`)
  157 +
  158 +### 6.3 Update Frontend
  159 +
  160 +**Files**: [`frontend/`](frontend/) HTML/JS files
  161 +
  162 +- Change index name references from `search_customer1` to `search_products`
  163 +- Use BASE config endpoints
  164 +- Archive old frontend as `frontend_legacy/` if needed
  165 +
  166 +## Phase 7: API Documentation Updates
  167 +
  168 +### 7.1 Update API Docs
  169 +
  170 +**File**: [`API_DOCUMENTATION.md`](API_DOCUMENTATION.md)
  171 +
  172 +**Critical additions**:
  173 +
  174 +- **Document `tenant_id` as REQUIRED parameter** in all search requests
  175 +- Explain that `tenant_id` filter is always applied
  176 +- Update all response examples to new Shoplazza format
  177 +- Document `suggestions` and `related_searches` (not yet implemented)
  178 +- Add nested variant query examples
  179 +- Multi-tenant isolation guarantees
  180 +
  181 +### 7.2 Update Request Models
  182 +
  183 +**File**: [`api/models.py`](api/models.py)
  184 +
  185 +Add `tenant_id` to `SearchRequest`:
  186 +
  187 +```python
  188 +class SearchRequest(BaseModel):
  189 + tenant_id: str = Field(..., description="租户ID (必需)")
  190 + query: str = Field(...)
  191 + # ... other fields
  192 +```
  193 +
  194 +## Phase 8: Design Documentation
  195 +
  196 +### 8.1 Update Design Doc
  197 +
  198 +**File**: [`设计文档.md`](设计文档.md)
  199 +
  200 +Updates:
  201 +
  202 +- **索引粒度**: 改为 SPU 维度(非SKU)
  203 +- **统一索引**: 所有租户共用 `search_products`,通过 `tenant_id` 隔离
  204 +- **BASE配置**: 说明BASE配置为通用标准,所有新商户使用
  205 +- **API响应格式**: 采用 Shoplazza 标准格式
  206 +- **Price扁平化**: 说明高频字段的性能优化策略
  207 +- **Nested变体**: 详细说明 variants 数组结构
  208 +- **Legacy配置**: customer1等为遗留配置,仅用于兼容
  209 +
  210 +### 8.2 Create BASE Guide
  211 +
  212 +**File**: [`docs/BASE_CONFIG_GUIDE.md`](docs/BASE_CONFIG_GUIDE.md)
  213 +
  214 +Contents:
  215 +
  216 +- BASE configuration overview
  217 +- How to generate test data
  218 +- How to run ingestion for new tenant
  219 +- Search examples
  220 +- Response format examples
  221 +- Multi-tenant isolation
  222 +
  223 +### 8.3 Create Migration Guide
  224 +
  225 +**File**: [`docs/MIGRATION_TO_BASE.md`](docs/MIGRATION_TO_BASE.md)
  226 +
  227 +- Breaking changes from SKU-level to SPU-level
  228 +- Response format changes
  229 +- How existing deployments should migrate
  230 +- Legacy config deprecation timeline
  231 +
  232 +## Phase 9: Testing
  233 +
  234 +### 9.1 Create Test Script
  235 +
  236 +**File**: [`scripts/test_base.sh`](scripts/test_base.sh)
  237 +
  238 +Steps:
  239 +
  240 +1. Generate 100 test SPU records
  241 +2. Run BASE ingestion with tenant_id="test_tenant"
  242 +3. Run searches, verify response format
  243 +4. Test faceted search
  244 +5. Verify multi-tenant isolation
  245 +6. Verify `tenant_id` filtering
  246 +
  247 +### 9.2 Integration Tests
  248 +
  249 +**File**: [`tests/test_base_integration.py`](tests/test_base_integration.py)
  250 +
  251 +- Test SPU-level indexing
  252 +- Test nested variant queries
  253 +- Test price filtering with flattened fields
  254 +- Test tenant_id isolation
  255 +- Test response transformation
  256 +
  257 +## Key Architectural Decisions
  258 +
  259 +### BASE Configuration Philosophy
  260 +
  261 +**BASE = Universal Standard**: All new merchants use BASE config with Shoplazza tables. No per-customer schema customization. Customization happens through:
  262 +
  263 +- Configuration parameters (analyzers, function_score, etc.)
  264 +- Extension tables (if needed for additional fields)
  265 +- NOT through separate schemas
  266 +
  267 +### Tenant Isolation
  268 +
  269 +**tenant_id is SACRED**:
  270 +
  271 +- Always present in queries (enforced at query builder)
  272 +- Never optional
  273 +- Guarantees data isolation between tenants
  274 +- Documented prominently in API docs
  275 +
  276 +### Price Flattening Rationale
  277 +
  278 +High-frequency operations (filtering, sorting) on price require optimal performance. Nested queries add overhead. Solution: Duplicate price data at SPU level (flattened) while maintaining full variant details in nested array.
  279 +
  280 +### Legacy vs BASE
  281 +
  282 +- **BASE**: New standard, all future merchants
  283 +- **Legacy (customer1_legacy)**: Deprecated, exists only for backward compatibility
  284 +- All scripts/frontend default to BASE
  285 +- Legacy access requires explicit suffix (`_legacy`)
  286 +
  287 +### To-dos
  288 +
  289 +- [ ] Analyze SPU/SKU fields and design unified schema with tenant_id, nested variants, and flattened price fields
  290 +- [ ] Create unified schema config for multi-tenant SPU-level indexing
  291 +- [ ] Add NESTED field type support to field_types.py and mapping generator
  292 +- [ ] Create SPU-level data transformer that joins SPU+SKU tables and creates nested variant array
  293 +- [ ] Create script to generate 100 realistic SPU+SKU test records in Shoplazza tables
  294 +- [ ] Create customer2 configuration using unified schema and Shoplazza tables only
  295 +- [ ] Create customer2 ingestion script that loads from MySQL Shoplazza tables
  296 +- [ ] Update query builder to support tenant_id filtering and nested variant queries
  297 +- [ ] Create response transformer to convert ES format to Shoplazza-compatible format
  298 +- [ ] Update API models with new Shoplazza response format (ProductResult, variants, suggestions, etc.)
  299 +- [ ] Update search routes to use response transformer and return new format
  300 +- [ ] Migrate customer1 configuration to unified schema and SPU-level indexing
  301 +- [ ] Create customer2 guide, update design docs, API docs, and create migration guide
  302 +- [ ] Create comprehensive test script for customer2 with data generation, ingestion, and search validation
0 303 \ No newline at end of file
... ...
SHOPLAZZA_INTEGRATION_GUIDE.md 0 → 100644
... ... @@ -0,0 +1,3226 @@
  1 +# 店匠平台技术对接指南
  2 +
  3 +## 1. 概述
  4 +
  5 +### 1.1 店匠平台介绍
  6 +
  7 +[店匠(Shoplazza)](https://www.shoplazza.com) 是一个专为跨境电商设计的独立站建站平台,类似于 Shopify。商家可以快速搭建自己的品牌独立站,进行商品销售、订单管理、客户管理等运营。
  8 +
  9 +店匠提供了开放的应用生态系统,第三方开发者可以开发应用插件(APP)并发布到店匠应用市场,为商家提供增值服务。
  10 +
  11 +**核心特性:**
  12 +- 独立站建站和主题装修
  13 +- 商品、订单、客户管理
  14 +- 多语言和多货币支持
  15 +- 开放的 Admin API
  16 +- Webhook 事件通知
  17 +- OAuth 2.0 授权机制
  18 +
  19 +### 1.2 对接目标
  20 +
  21 +本文档旨在帮助开发团队将**搜索 SaaS** 接入店匠生态,作为应用市场的搜索插件上线。
  22 +
  23 +**对接目标:**
  24 +1. 在店匠应用市场发布搜索 APP
  25 +2. 商家可以安装 APP 并授权访问店铺数据
  26 +3. 自动同步商家的商品、订单、客户数据
  27 +4. 提供前端搜索扩展,嵌入商家的店铺主题
  28 +5. 为商家提供智能搜索服务(多语言、语义搜索、AI 搜索)
  29 +6. 统计分析商家的搜索行为数据
  30 +
  31 +### 1.3 系统架构
  32 +
  33 +```mermaid
  34 +graph TB
  35 + subgraph "店匠平台"
  36 + A[店匠应用市场]
  37 + B[商家店铺]
  38 + C[店匠 Admin API]
  39 + D[店匠 Webhook]
  40 + end
  41 +
  42 + subgraph "搜索 SaaS 平台"
  43 + E[OAuth 服务]
  44 + F[数据同步服务]
  45 + G[Webhook 接收服务]
  46 + H[搜索 API 服务]
  47 + I[管理后台]
  48 + J[数据库<br/>MySQL]
  49 + K[搜索引擎<br/>Elasticsearch]
  50 + end
  51 +
  52 + subgraph "前端扩展"
  53 + L[搜索入口组件]
  54 + M[搜索结果页]
  55 + end
  56 +
  57 + A -->|商家安装| E
  58 + B -->|OAuth授权| E
  59 + E -->|获取Token| F
  60 + F -->|调用API| C
  61 + C -->|返回数据| F
  62 + F -->|存储| J
  63 + F -->|索引| K
  64 + D -->|推送事件| G
  65 + G -->|增量更新| J
  66 + G -->|增量索引| K
  67 + B -->|装修主题| L
  68 + L -->|搜索请求| H
  69 + M -->|搜索请求| H
  70 + H -->|查询| K
  71 + I -->|管理| J
  72 +```
  73 +
  74 +### 1.4 技术栈要求
  75 +
  76 +**后端服务:**
  77 +- Java(Spring Boot):OAuth、数据同步、API 网关
  78 +- Python(FastAPI):搜索服务、向量检索
  79 +- MySQL:存储店铺、商品、订单等数据
  80 +- Elasticsearch:商品索引和搜索
  81 +
  82 +**前端扩展:**
  83 +- Liquid 模板语言(店匠主题)
  84 +- JavaScript/TypeScript
  85 +- HTML/CSS
  86 +
  87 +**基础设施:**
  88 +- 公网域名(支持 HTTPS)
  89 +- SSL 证书
  90 +- 服务器(支持 Docker 部署)
  91 +
  92 +### 1.5 前置条件
  93 +
  94 +在开始对接之前,请确保:
  95 +
  96 +1. ✅ 已注册店匠 Partner 账号
  97 +2. ✅ 拥有公网域名和 HTTPS 证书
  98 +3. ✅ 已部署搜索 SaaS 后端服务
  99 +4. ✅ 拥有测试店铺(用于开发和调试)
  100 +5. ✅ 熟悉 OAuth 2.0 授权流程
  101 +6. ✅ 熟悉 RESTful API 开发
  102 +
  103 +---
  104 +
  105 +## 2. 开发者准备
  106 +
  107 +### 2.1 注册店匠 Partner 账号
  108 +
  109 +1. 访问 [店匠合作伙伴中心](https://partners.shoplazza.com)
  110 +2. 点击"注册"按钮,填写公司信息
  111 +3. 完成邮箱验证和资质审核
  112 +4. 登录 Partner 后台
  113 +
  114 +### 2.2 创建 APP 应用
  115 +
  116 +1. 登录 [店匠 Partner 后台](https://partners.shoplazza.com)
  117 +2. 在左侧导航栏选择"Apps"
  118 +3. 点击"Create App"按钮
  119 +4. 填写 APP 基本信息:
  120 + - **App Name**:搜索 SaaS(或自定义名称)
  121 + - **App Type**:Public App(公开应用)
  122 + - **Category**:Search & Discovery(搜索与发现)
  123 +
  124 +5. 系统自动生成:
  125 + - **Client ID**:应用的唯一标识
  126 + - **Client Secret**:应用密钥(请妥善保管)
  127 +
  128 +### 2.3 配置 APP 信息
  129 +
  130 +在 APP 设置页面,配置以下关键信息:
  131 +
  132 +#### 2.3.1 OAuth 配置
  133 +
  134 +```yaml
  135 +Client ID: m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es
  136 +Client Secret: m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo
  137 +```
  138 +
  139 +**重定向 URI(Redirect URI):**
  140 +```
  141 +https://your-domain.com/oauth/callback
  142 +```
  143 +
  144 +**注意事项:**
  145 +- Redirect URI 必须使用 HTTPS 协议
  146 +- 必须是公网可访问的地址
  147 +- 开发环境可以使用 ngrok 等工具暴露本地服务
  148 +
  149 +#### 2.3.2 应用权限(Scopes)
  150 +
  151 +根据业务需求,申请以下权限:
  152 +
  153 +| 权限 Scope | 说明 | 是否必需 |
  154 +|------------|------|----------|
  155 +| `read_shop` | 读取店铺信息 | ✅ 必需 |
  156 +| `write_shop` | 修改店铺信息 | ❌ 可选 |
  157 +| `read_product` | 读取商品信息 | ✅ 必需 |
  158 +| `write_product` | 修改商品信息 | ❌ 可选 |
  159 +| `read_order` | 读取订单信息 | ✅ 必需 |
  160 +| `read_customer` | 读取客户信息 | ✅ 必需 |
  161 +| `read_app_proxy` | APP 代理访问 | ✅ 必需 |
  162 +| `write_cart_transform` | 购物车转换(如需价格调整) | ❌ 可选 |
  163 +
  164 +**配置示例:**
  165 +```go
  166 +Scopes: []string{
  167 + "read_shop",
  168 + "read_product",
  169 + "read_order",
  170 + "read_customer",
  171 + "read_app_proxy",
  172 +}
  173 +```
  174 +
  175 +#### 2.3.3 Webhook 配置(后续注册)
  176 +
  177 +Webhook 地址(后续在代码中动态注册):
  178 +```
  179 +https://your-domain.com/webhook/shoplazza
  180 +```
  181 +
  182 +### 2.4 准备测试店铺
  183 +
  184 +1. 在店匠平台注册一个测试店铺
  185 +2. 在店铺中添加测试商品、客户、订单数据
  186 +3. 记录店铺域名:`{shop-name}.myshoplaza.com`
  187 +
  188 +**注意:** 部分功能(如 Webhook 注册)需要店铺激活后才能使用。
  189 +
  190 +---
  191 +
  192 +## 3. OAuth 2.0 认证实现
  193 +
  194 +### 3.1 OAuth 授权流程
  195 +
  196 +店匠使用标准的 OAuth 2.0 授权码(Authorization Code)流程:
  197 +
  198 +```mermaid
  199 +sequenceDiagram
  200 + participant 商家
  201 + participant 店匠平台
  202 + participant 搜索SaaS
  203 +
  204 + 商家->>店匠平台: 1. 在应用市场点击"安装"
  205 + 店匠平台->>搜索SaaS: 2. 跳转到 APP URI
  206 + 搜索SaaS->>店匠平台: 3. 重定向到授权页面
  207 + 店匠平台->>商家: 4. 显示授权确认页
  208 + 商家->>店匠平台: 5. 点击"授权"
  209 + 店匠平台->>搜索SaaS: 6. 回调 Redirect URI(带 code)
  210 + 搜索SaaS->>店匠平台: 7. 用 code 换取 Access Token
  211 + 店匠平台->>搜索SaaS: 8. 返回 Access Token
  212 + 搜索SaaS->>搜索SaaS: 9. 保存 Token 到数据库
  213 + 搜索SaaS->>商家: 10. 显示安装成功页面
  214 +```
  215 +
  216 +### 3.2 实现步骤
  217 +
  218 +#### 3.2.1 配置 OAuth 客户端
  219 +
  220 +在应用启动时,初始化 OAuth 配置:
  221 +
  222 +```go
  223 +// OAuth 配置
  224 +type OAuthConfig struct {
  225 + ClientID string
  226 + ClientSecret string
  227 + RedirectURI string
  228 + Scopes []string
  229 + AuthURL string
  230 + TokenURL string
  231 +}
  232 +
  233 +// 初始化配置
  234 +config := &OAuthConfig{
  235 + ClientID: "m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es",
  236 + ClientSecret: "m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo",
  237 + RedirectURI: "https://your-domain.com/oauth/callback",
  238 + Scopes: []string{
  239 + "read_shop",
  240 + "read_product",
  241 + "read_order",
  242 + "read_customer",
  243 + "read_app_proxy",
  244 + },
  245 + AuthURL: "https://partners.shoplazza.com/partner/oauth/authorize",
  246 + TokenURL: "https://partners.shoplazza.com/partner/oauth/token",
  247 +}
  248 +```
  249 +
  250 +#### 3.2.2 处理 APP URI 请求
  251 +
  252 +当商家在应用市场点击"安装"时,店匠会跳转到你配置的 APP URI:
  253 +
  254 +```
  255 +GET https://your-domain.com/oauth/install?shop={shop_domain}
  256 +```
  257 +
  258 +**处理逻辑:**
  259 +
  260 +```http
  261 +GET /oauth/install
  262 +Query Parameters:
  263 + - shop: 店铺域名,例如 47167113-1.myshoplaza.com
  264 +
  265 +Response:
  266 + 302 Redirect to Authorization URL
  267 +```
  268 +
  269 +**生成授权 URL:**
  270 +
  271 +```go
  272 +// 构建授权 URL
  273 +func buildAuthURL(config *OAuthConfig, shop string) string {
  274 + params := url.Values{}
  275 + params.Add("client_id", config.ClientID)
  276 + params.Add("redirect_uri", config.RedirectURI)
  277 + params.Add("scope", strings.Join(config.Scopes, " "))
  278 + params.Add("state", shop) // 使用 shop 作为 state
  279 +
  280 + return config.AuthURL + "?" + params.Encode()
  281 +}
  282 +```
  283 +
  284 +**授权 URL 示例:**
  285 +```
  286 +https://partners.shoplazza.com/partner/oauth/authorize?
  287 + client_id=m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es
  288 + &redirect_uri=https://your-domain.com/oauth/callback
  289 + &scope=read_shop read_product read_order read_customer read_app_proxy
  290 + &state=47167113-1.myshoplaza.com
  291 +```
  292 +
  293 +#### 3.2.3 处理授权回调
  294 +
  295 +商家授权后,店匠会回调你的 Redirect URI:
  296 +
  297 +```
  298 +GET https://your-domain.com/oauth/callback?code={auth_code}&shop={shop_domain}&state={state}
  299 +```
  300 +
  301 +**回调参数:**
  302 +- `code`:授权码(用于换取 Access Token)
  303 +- `shop`:店铺域名
  304 +- `state`:之前传递的 state 参数
  305 +
  306 +**处理逻辑:**
  307 +
  308 +```http
  309 +GET /oauth/callback
  310 +Query Parameters:
  311 + - code: 授权码
  312 + - shop: 店铺域名
  313 + - state: state 参数
  314 +
  315 +Response:
  316 + 200 OK (HTML 页面显示安装成功)
  317 +```
  318 +
  319 +#### 3.2.4 换取 Access Token
  320 +
  321 +使用授权码换取 Access Token:
  322 +
  323 +**请求示例(curl):**
  324 +
  325 +```bash
  326 +curl --request POST \
  327 + --url https://partners.shoplazza.com/partner/oauth/token \
  328 + --header 'Content-Type: application/json' \
  329 + --data '{
  330 + "client_id": "m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es",
  331 + "client_secret": "m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo",
  332 + "code": "{authorization_code}",
  333 + "grant_type": "authorization_code",
  334 + "redirect_uri": "https://your-domain.com/oauth/callback"
  335 + }'
  336 +```
  337 +
  338 +**响应示例:**
  339 +
  340 +```json
  341 +{
  342 + "access_token": "V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY",
  343 + "token_type": "Bearer",
  344 + "refresh_token": "-QP6o5YpsqC47q5D2M3xHJ0YP4SPcybhm5oYlPaMUOo",
  345 + "expires_in": 31556951,
  346 + "created_at": 1740793402,
  347 + "store_id": "2286274",
  348 + "store_name": "47167113-1",
  349 + "expires_at": 1772350354,
  350 + "locale": "zh-CN"
  351 +}
  352 +```
  353 +
  354 +**响应字段说明:**
  355 +- `access_token`:访问令牌(调用 Admin API 时使用)
  356 +- `refresh_token`:刷新令牌(用于刷新 Access Token)
  357 +- `expires_in`:过期时间(秒)
  358 +- `expires_at`:过期时间戳
  359 +- `store_id`:店铺 ID
  360 +- `store_name`:店铺名称
  361 +- `locale`:店铺语言
  362 +
  363 +#### 3.2.5 保存 Token 到数据库
  364 +
  365 +将 Token 信息保存到数据库(详见第 4 章数据模型):
  366 +
  367 +```sql
  368 +INSERT INTO shoplazza_shop_config (
  369 + store_id,
  370 + store_name,
  371 + store_domain,
  372 + access_token,
  373 + refresh_token,
  374 + token_expires_at,
  375 + locale,
  376 + status,
  377 + created_at,
  378 + updated_at
  379 +) VALUES (
  380 + '2286274',
  381 + '47167113-1',
  382 + '47167113-1.myshoplaza.com',
  383 + 'V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY',
  384 + '-QP6o5YpsqC47q5D2M3xHJ0YP4SPcybhm5oYlPaMUOo',
  385 + '2026-11-02 23:21:14',
  386 + 'zh-CN',
  387 + 'active',
  388 + NOW(),
  389 + NOW()
  390 +);
  391 +```
  392 +
  393 +### 3.3 Token 刷新机制
  394 +
  395 +Access Token 会过期(通常为 1 年),过期后需要使用 Refresh Token 刷新。
  396 +
  397 +**刷新 Token 请求:**
  398 +
  399 +```bash
  400 +curl --request POST \
  401 + --url https://partners.shoplazza.com/partner/oauth/token \
  402 + --header 'Content-Type: application/json' \
  403 + --data '{
  404 + "client_id": "m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es",
  405 + "client_secret": "m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo",
  406 + "refresh_token": "-QP6o5YpsqC47q5D2M3xHJ0YP4SPcybhm5oYlPaMUOo",
  407 + "grant_type": "refresh_token"
  408 + }'
  409 +```
  410 +
  411 +**响应格式与获取 Token 时相同。**
  412 +
  413 +**刷新策略:**
  414 +1. 在 Token 过期前 7 天开始尝试刷新
  415 +2. API 调用返回 401 Unauthorized 时立即刷新
  416 +3. 刷新成功后更新数据库中的 Token 信息
  417 +
  418 +### 3.4 安装成功页面
  419 +
  420 +OAuth 回调处理完成后,返回一个 HTML 页面告知商家安装成功:
  421 +
  422 +```html
  423 +<!DOCTYPE html>
  424 +<html>
  425 +<head>
  426 + <meta charset="UTF-8">
  427 + <title>安装成功 - 搜索 SaaS</title>
  428 + <style>
  429 + body {
  430 + font-family: Arial, sans-serif;
  431 + max-width: 600px;
  432 + margin: 50px auto;
  433 + text-align: center;
  434 + padding: 20px;
  435 + }
  436 + .success-icon {
  437 + font-size: 64px;
  438 + color: #52c41a;
  439 + margin-bottom: 20px;
  440 + }
  441 + h1 {
  442 + color: #333;
  443 + margin-bottom: 10px;
  444 + }
  445 + p {
  446 + color: #666;
  447 + line-height: 1.6;
  448 + }
  449 + .next-steps {
  450 + background: #f5f5f5;
  451 + padding: 20px;
  452 + border-radius: 8px;
  453 + margin-top: 30px;
  454 + text-align: left;
  455 + }
  456 + .next-steps h2 {
  457 + font-size: 18px;
  458 + margin-top: 0;
  459 + }
  460 + .next-steps ol {
  461 + margin: 10px 0;
  462 + padding-left: 20px;
  463 + }
  464 + .btn {
  465 + display: inline-block;
  466 + background: #1890ff;
  467 + color: white;
  468 + padding: 12px 24px;
  469 + border-radius: 4px;
  470 + text-decoration: none;
  471 + margin-top: 20px;
  472 + }
  473 + </style>
  474 +</head>
  475 +<body>
  476 + <div class="success-icon">✓</div>
  477 + <h1>安装成功!</h1>
  478 + <p>搜索 SaaS 已成功安装到您的店铺</p>
  479 + <p>店铺名称:<strong>{{store_name}}</strong></p>
  480 +
  481 + <div class="next-steps">
  482 + <h2>下一步操作:</h2>
  483 + <ol>
  484 + <li>进入店铺后台 → 主题装修</li>
  485 + <li>点击"添加卡片" → 选择"APPS" → 找到"搜索 SaaS"</li>
  486 + <li>拖拽搜索组件到页面中</li>
  487 + <li>保存并发布主题</li>
  488 + </ol>
  489 + </div>
  490 +
  491 + <a href="https://{{shop_domain}}/admin" class="btn">前往店铺后台</a>
  492 +</body>
  493 +</html>
  494 +```
  495 +
  496 +---
  497 +
  498 +## 4. 租户和店铺管理
  499 +
  500 +### 4.1 数据模型设计
  501 +
  502 +#### 4.1.1 租户表(system_tenant)
  503 +
  504 +每个店铺在 SaaS 平台都是一个独立的租户。
  505 +
  506 +```sql
  507 +CREATE TABLE `system_tenant` (
  508 + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '租户ID',
  509 + `name` VARCHAR(255) NOT NULL COMMENT '租户名称',
  510 + `package_id` BIGINT DEFAULT NULL COMMENT '套餐ID',
  511 + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
  512 + `expire_time` DATETIME DEFAULT NULL COMMENT '过期时间',
  513 + `account_count` INT DEFAULT 0 COMMENT '账号数量',
  514 + `creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
  515 + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  516 + `updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
  517 + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  518 + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  519 + PRIMARY KEY (`id`),
  520 + KEY `idx_name` (`name`)
  521 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户表';
  522 +```
  523 +
  524 +#### 4.1.2 店铺配置表(shoplazza_shop_config)
  525 +
  526 +存储店铺的基本信息和 OAuth Token。
  527 +
  528 +```sql
  529 +CREATE TABLE `shoplazza_shop_config` (
  530 + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  531 + `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  532 + `store_id` VARCHAR(64) NOT NULL COMMENT '店匠店铺ID',
  533 + `store_name` VARCHAR(255) NOT NULL COMMENT '店铺名称',
  534 + `store_domain` VARCHAR(255) NOT NULL COMMENT '店铺域名',
  535 + `access_token` VARCHAR(512) NOT NULL COMMENT 'Access Token',
  536 + `refresh_token` VARCHAR(512) DEFAULT NULL COMMENT 'Refresh Token',
  537 + `token_expires_at` DATETIME NOT NULL COMMENT 'Token过期时间',
  538 + `locale` VARCHAR(16) DEFAULT 'zh-CN' COMMENT '店铺语言',
  539 + `currency` VARCHAR(16) DEFAULT 'USD' COMMENT '店铺货币',
  540 + `timezone` VARCHAR(64) DEFAULT 'Asia/Shanghai' COMMENT '店铺时区',
  541 + `status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active-激活,inactive-未激活,suspended-暂停',
  542 + `install_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '安装时间',
  543 + `last_sync_time` DATETIME DEFAULT NULL COMMENT '最后同步时间',
  544 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  545 + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  546 + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  547 + PRIMARY KEY (`id`),
  548 + UNIQUE KEY `uk_store_id` (`store_id`),
  549 + KEY `idx_tenant_id` (`tenant_id`),
  550 + KEY `idx_store_domain` (`store_domain`)
  551 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠店铺配置表';
  552 +```
  553 +
  554 +### 4.2 Token 管理策略
  555 +
  556 +#### 4.2.1 Token 存储
  557 +
  558 +- ✅ 加密存储 Access Token 和 Refresh Token
  559 +- ✅ 记录 Token 过期时间
  560 +- ✅ 记录最后刷新时间
  561 +
  562 +#### 4.2.2 Token 自动刷新
  563 +
  564 +```java
  565 +public class TokenRefreshService {
  566 +
  567 + /**
  568 + * 检查并刷新即将过期的 Token
  569 + */
  570 + @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
  571 + public void refreshExpiringTokens() {
  572 + // 查询7天内过期的 Token
  573 + DateTime sevenDaysLater = DateTime.now().plusDays(7);
  574 + List<ShopConfig> shops = shopConfigMapper.selectExpiringTokens(sevenDaysLater);
  575 +
  576 + for (ShopConfig shop : shops) {
  577 + try {
  578 + // 刷新 Token
  579 + TokenResponse newToken = oauthClient.refreshToken(shop.getRefreshToken());
  580 +
  581 + // 更新数据库
  582 + shop.setAccessToken(newToken.getAccessToken());
  583 + shop.setRefreshToken(newToken.getRefreshToken());
  584 + shop.setTokenExpiresAt(newToken.getExpiresAt());
  585 + shopConfigMapper.updateById(shop);
  586 +
  587 + log.info("Token refreshed for shop: {}", shop.getStoreName());
  588 + } catch (Exception e) {
  589 + log.error("Failed to refresh token for shop: {}", shop.getStoreName(), e);
  590 + // 可选:发送告警通知
  591 + }
  592 + }
  593 + }
  594 +
  595 + /**
  596 + * API 调用时检查 Token 是否过期
  597 + */
  598 + public String getValidAccessToken(String storeId) {
  599 + ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
  600 +
  601 + if (shop == null) {
  602 + throw new BusinessException("Shop not found: " + storeId);
  603 + }
  604 +
  605 + // 检查是否即将过期(提前1小时)
  606 + if (shop.getTokenExpiresAt().isBefore(DateTime.now().plusHours(1))) {
  607 + // 刷新 Token
  608 + TokenResponse newToken = oauthClient.refreshToken(shop.getRefreshToken());
  609 +
  610 + // 更新数据库
  611 + shop.setAccessToken(newToken.getAccessToken());
  612 + shop.setRefreshToken(newToken.getRefreshToken());
  613 + shop.setTokenExpiresAt(newToken.getExpiresAt());
  614 + shopConfigMapper.updateById(shop);
  615 + }
  616 +
  617 + return shop.getAccessToken();
  618 + }
  619 +}
  620 +```
  621 +
  622 +### 4.3 租户创建流程
  623 +
  624 +当商家完成 OAuth 授权后,自动创建租户和店铺配置:
  625 +
  626 +```java
  627 +@Transactional
  628 +public void handleOAuthCallback(TokenResponse tokenResponse) {
  629 + String storeId = tokenResponse.getStoreId();
  630 + String storeName = tokenResponse.getStoreName();
  631 +
  632 + // 1. 检查租户是否已存在
  633 + Tenant tenant = tenantMapper.selectByStoreId(storeId);
  634 + if (tenant == null) {
  635 + // 创建新租户
  636 + tenant = new Tenant();
  637 + tenant.setName(storeName);
  638 + tenant.setStatus(1); // 启用
  639 + tenant.setPackageId(1L); // 默认套餐
  640 + tenantMapper.insert(tenant);
  641 + }
  642 +
  643 + // 2. 创建或更新店铺配置
  644 + ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
  645 + if (shop == null) {
  646 + shop = new ShopConfig();
  647 + shop.setTenantId(tenant.getId());
  648 + shop.setStoreId(storeId);
  649 + shop.setStoreName(storeName);
  650 + }
  651 +
  652 + // 更新 Token 信息
  653 + shop.setAccessToken(tokenResponse.getAccessToken());
  654 + shop.setRefreshToken(tokenResponse.getRefreshToken());
  655 + shop.setTokenExpiresAt(tokenResponse.getExpiresAt());
  656 + shop.setLocale(tokenResponse.getLocale());
  657 + shop.setStatus("active");
  658 + shop.setInstallTime(new Date());
  659 +
  660 + if (shop.getId() == null) {
  661 + shopConfigMapper.insert(shop);
  662 + } else {
  663 + shopConfigMapper.updateById(shop);
  664 + }
  665 +
  666 + // 3. 触发首次数据同步
  667 + dataSyncService.syncAllData(shop.getId());
  668 +}
  669 +```
  670 +
  671 +---
  672 +
  673 +## 5. 店匠 Admin API 调用
  674 +
  675 +### 5.1 API 认证方式
  676 +
  677 +调用店匠 Admin API 时,需要在请求头中携带 Access Token:
  678 +
  679 +```http
  680 +GET /openapi/2022-01/products
  681 +Host: {shop-domain}.myshoplaza.com
  682 +access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY
  683 +Content-Type: application/json
  684 +```
  685 +
  686 +**注意:** 请求头字段名是 `access-token`,不是 `Authorization`。
  687 +
  688 +### 5.2 API 端点基础 URL
  689 +
  690 +店匠 API 的基础 URL 格式:
  691 +
  692 +```
  693 +https://{shop-domain}.myshoplaza.com/openapi/{version}/{resource}
  694 +```
  695 +
  696 +**参数说明:**
  697 +- `{shop-domain}`:店铺域名,例如 `47167113-1`
  698 +- `{version}`:API 版本,目前为 `2022-01`
  699 +- `{resource}`:资源路径,例如 `products`、`orders`
  700 +
  701 +**示例:**
  702 +```
  703 +https://47167113-1.myshoplaza.com/openapi/2022-01/products
  704 +```
  705 +
  706 +### 5.3 常用 API 端点
  707 +
  708 +#### 5.3.1 店铺信息
  709 +
  710 +```bash
  711 +# 获取店铺详情
  712 +GET /openapi/2022-01/shop
  713 +```
  714 +
  715 +#### 5.3.2 商品管理
  716 +
  717 +```bash
  718 +# 获取商品列表
  719 +GET /openapi/2022-01/products?page=1&limit=50
  720 +
  721 +# 获取商品详情
  722 +GET /openapi/2022-01/products/{product_id}
  723 +
  724 +# 获取商品总数
  725 +GET /openapi/2022-01/products/count
  726 +```
  727 +
  728 +#### 5.3.3 订单管理
  729 +
  730 +```bash
  731 +# 获取订单列表
  732 +GET /openapi/2022-01/orders?page=1&limit=50
  733 +
  734 +# 获取订单详情
  735 +GET /openapi/2022-01/orders/{order_id}
  736 +
  737 +# 获取订单总数
  738 +GET /openapi/2022-01/orders/count
  739 +```
  740 +
  741 +#### 5.3.4 客户管理
  742 +
  743 +```bash
  744 +# 获取客户列表
  745 +GET /openapi/2022-01/customers?page=1&limit=50
  746 +
  747 +# 获取客户详情
  748 +GET /openapi/2022-01/customers/{customer_id}
  749 +
  750 +# 获取客户总数
  751 +GET /openapi/2022-01/customers/count
  752 +```
  753 +
  754 +### 5.4 请求和响应格式
  755 +
  756 +#### 5.4.1 分页查询
  757 +
  758 +店匠 API 使用基于页码的分页:
  759 +
  760 +```http
  761 +GET /openapi/2022-01/products?page=1&limit=50&status=active
  762 +```
  763 +
  764 +**分页参数:**
  765 +- `page`:页码,从 1 开始
  766 +- `limit`:每页数量,最大 250
  767 +
  768 +**响应格式:**
  769 +```json
  770 +{
  771 + "products": [
  772 + {
  773 + "id": "123456",
  774 + "title": "Product Name",
  775 + "variants": [...],
  776 + ...
  777 + }
  778 + ]
  779 +}
  780 +```
  781 +
  782 +#### 5.4.2 错误响应
  783 +
  784 +API 调用失败时返回错误信息:
  785 +
  786 +```json
  787 +{
  788 + "error": "Unauthorized",
  789 + "error_description": "Invalid access token"
  790 +}
  791 +```
  792 +
  793 +**常见错误码:**
  794 +- `400 Bad Request`:请求参数错误
  795 +- `401 Unauthorized`:Token 无效或过期
  796 +- `403 Forbidden`:权限不足
  797 +- `404 Not Found`:资源不存在
  798 +- `429 Too Many Requests`:触发速率限制
  799 +- `500 Internal Server Error`:服务器错误
  800 +
  801 +### 5.5 错误处理和重试策略
  802 +
  803 +```java
  804 +public class ShoplazzaApiClient {
  805 +
  806 + private static final int MAX_RETRIES = 3;
  807 + private static final int RETRY_DELAY_MS = 1000;
  808 +
  809 + /**
  810 + * 调用 API 并处理错误
  811 + */
  812 + public <T> T callApi(String storeId, String endpoint, Class<T> responseType) {
  813 + int retries = 0;
  814 + Exception lastException = null;
  815 +
  816 + while (retries < MAX_RETRIES) {
  817 + try {
  818 + // 获取有效的 Access Token
  819 + String accessToken = tokenManager.getValidAccessToken(storeId);
  820 +
  821 + // 构建请求
  822 + HttpHeaders headers = new HttpHeaders();
  823 + headers.set("access-token", accessToken);
  824 + headers.setContentType(MediaType.APPLICATION_JSON);
  825 +
  826 + HttpEntity<?> entity = new HttpEntity<>(headers);
  827 +
  828 + // 发送请求
  829 + ResponseEntity<T> response = restTemplate.exchange(
  830 + endpoint,
  831 + HttpMethod.GET,
  832 + entity,
  833 + responseType
  834 + );
  835 +
  836 + return response.getBody();
  837 +
  838 + } catch (HttpClientErrorException e) {
  839 + if (e.getStatusCode() == HttpStatus.UNAUTHORIZED) {
  840 + // Token 过期,刷新后重试
  841 + tokenManager.forceRefreshToken(storeId);
  842 + retries++;
  843 + continue;
  844 + } else if (e.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) {
  845 + // 触发速率限制,等待后重试
  846 + sleep(RETRY_DELAY_MS * (retries + 1));
  847 + retries++;
  848 + continue;
  849 + } else {
  850 + throw new BusinessException("API call failed: " + e.getMessage());
  851 + }
  852 + } catch (Exception e) {
  853 + lastException = e;
  854 + retries++;
  855 + sleep(RETRY_DELAY_MS);
  856 + }
  857 + }
  858 +
  859 + throw new BusinessException("API call failed after retries", lastException);
  860 + }
  861 +
  862 + private void sleep(long millis) {
  863 + try {
  864 + Thread.sleep(millis);
  865 + } catch (InterruptedException e) {
  866 + Thread.currentThread().interrupt();
  867 + }
  868 + }
  869 +}
  870 +```
  871 +
  872 +### 5.6 速率限制处理
  873 +
  874 +店匠 API 有速率限制(Rate Limit),需要遵守以下规则:
  875 +
  876 +**限制说明:**
  877 +- 每个店铺每秒最多 10 个请求
  878 +- 响应头中包含速率限制信息
  879 +
  880 +**响应头示例:**
  881 +```
  882 +X-RateLimit-Limit: 10
  883 +X-RateLimit-Remaining: 8
  884 +X-RateLimit-Reset: 1699800060
  885 +```
  886 +
  887 +**处理策略:**
  888 +1. 解析响应头中的速率限制信息
  889 +2. 如果 `X-RateLimit-Remaining` 为 0,等待到 `X-RateLimit-Reset` 时间
  890 +3. 收到 429 错误时,使用指数退避重试
  891 +
  892 +---
  893 +
  894 +## 6. 数据同步实现
  895 +
  896 +### 6.1 商品数据同步
  897 +
  898 +#### 6.1.1 API 调用
  899 +
  900 +**获取商品列表:**
  901 +
  902 +```bash
  903 +curl --request GET \
  904 + --url 'https://47167113-1.myshoplaza.com/openapi/2022-01/products?page=1&limit=50' \
  905 + --header 'access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY' \
  906 + --header 'accept: application/json'
  907 +```
  908 +
  909 +**响应示例:**
  910 +
  911 +```json
  912 +{
  913 + "products": [
  914 + {
  915 + "id": "193817395",
  916 + "title": "蓝牙耳机",
  917 + "body_html": "<p>高品质蓝牙耳机</p>",
  918 + "vendor": "Sony",
  919 + "product_type": "Electronics",
  920 + "handle": "bluetooth-headphone",
  921 + "published_at": "2024-01-15T10:00:00Z",
  922 + "created_at": "2024-01-15T09:00:00Z",
  923 + "updated_at": "2024-01-20T14:30:00Z",
  924 + "status": "active",
  925 + "tags": "electronics, audio, bluetooth",
  926 + "variants": [
  927 + {
  928 + "id": "819403847",
  929 + "product_id": "193817395",
  930 + "title": "Black / Standard",
  931 + "price": "99.99",
  932 + "compare_at_price": "129.99",
  933 + "sku": "BT-HP-001",
  934 + "inventory_quantity": 100,
  935 + "weight": "0.25",
  936 + "weight_unit": "kg",
  937 + "requires_shipping": true,
  938 + "option1": "Black",
  939 + "option2": "Standard",
  940 + "option3": null
  941 + }
  942 + ],
  943 + "images": [
  944 + {
  945 + "id": "638746512",
  946 + "product_id": "193817395",
  947 + "src": "https://cdn.shoplazza.com/image1.jpg",
  948 + "position": 1,
  949 + "width": 800,
  950 + "height": 800
  951 + }
  952 + ],
  953 + "options": [
  954 + {
  955 + "id": "123456",
  956 + "name": "Color",
  957 + "values": ["Black", "White", "Blue"]
  958 + },
  959 + {
  960 + "id": "123457",
  961 + "name": "Size",
  962 + "values": ["Standard"]
  963 + }
  964 + ]
  965 + }
  966 + ]
  967 +}
  968 +```
  969 +
  970 +#### 6.1.2 数据表设计
  971 +
  972 +**SPU 表(shoplazza_product_spu):**
  973 +
  974 +```sql
  975 +CREATE TABLE `shoplazza_product_spu` (
  976 + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  977 + `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  978 + `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
  979 + `product_id` VARCHAR(64) NOT NULL COMMENT '店匠商品ID',
  980 + `title` VARCHAR(512) NOT NULL COMMENT '商品标题',
  981 + `body_html` TEXT COMMENT '商品描述HTML',
  982 + `vendor` VARCHAR(255) DEFAULT NULL COMMENT '供应商/品牌',
  983 + `product_type` VARCHAR(255) DEFAULT NULL COMMENT '商品类型',
  984 + `handle` VARCHAR(255) DEFAULT NULL COMMENT '商品URL handle',
  985 + `tags` VARCHAR(1024) DEFAULT NULL COMMENT '标签(逗号分隔)',
  986 + `status` VARCHAR(32) DEFAULT 'active' COMMENT '状态:active, draft, archived',
  987 + `published_at` DATETIME DEFAULT NULL COMMENT '发布时间',
  988 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  989 + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  990 + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  991 + PRIMARY KEY (`id`),
  992 + UNIQUE KEY `uk_store_product` (`store_id`, `product_id`),
  993 + KEY `idx_tenant_id` (`tenant_id`),
  994 + KEY `idx_product_type` (`product_type`),
  995 + KEY `idx_vendor` (`vendor`)
  996 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠商品SPU表';
  997 +```
  998 +
  999 +**SKU 表(shoplazza_product_sku):**
  1000 +
  1001 +```sql
  1002 +CREATE TABLE `shoplazza_product_sku` (
  1003 + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  1004 + `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  1005 + `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
  1006 + `product_id` VARCHAR(64) NOT NULL COMMENT '店匠商品ID',
  1007 + `variant_id` VARCHAR(64) NOT NULL COMMENT '店匠变体ID',
  1008 + `sku` VARCHAR(255) DEFAULT NULL COMMENT 'SKU编码',
  1009 + `title` VARCHAR(512) NOT NULL COMMENT '变体标题',
  1010 + `price` DECIMAL(12,2) NOT NULL COMMENT '价格',
  1011 + `compare_at_price` DECIMAL(12,2) DEFAULT NULL COMMENT '对比价格',
  1012 + `inventory_quantity` INT DEFAULT 0 COMMENT '库存数量',
  1013 + `weight` DECIMAL(10,3) DEFAULT NULL COMMENT '重量',
  1014 + `weight_unit` VARCHAR(16) DEFAULT NULL COMMENT '重量单位',
  1015 + `option1` VARCHAR(255) DEFAULT NULL COMMENT '选项1值',
  1016 + `option2` VARCHAR(255) DEFAULT NULL COMMENT '选项2值',
  1017 + `option3` VARCHAR(255) DEFAULT NULL COMMENT '选项3值',
  1018 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  1019 + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  1020 + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  1021 + PRIMARY KEY (`id`),
  1022 + UNIQUE KEY `uk_store_variant` (`store_id`, `variant_id`),
  1023 + KEY `idx_tenant_id` (`tenant_id`),
  1024 + KEY `idx_product_id` (`product_id`),
  1025 + KEY `idx_sku` (`sku`)
  1026 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠商品SKU表';
  1027 +```
  1028 +
  1029 +**图片表(shoplazza_product_image):**
  1030 +
  1031 +```sql
  1032 +CREATE TABLE `shoplazza_product_image` (
  1033 + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  1034 + `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  1035 + `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
  1036 + `product_id` VARCHAR(64) NOT NULL COMMENT '店匠商品ID',
  1037 + `image_id` VARCHAR(64) NOT NULL COMMENT '店匠图片ID',
  1038 + `src` VARCHAR(1024) NOT NULL COMMENT '图片URL',
  1039 + `position` INT DEFAULT 1 COMMENT '排序位置',
  1040 + `width` INT DEFAULT NULL COMMENT '图片宽度',
  1041 + `height` INT DEFAULT NULL COMMENT '图片高度',
  1042 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  1043 + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  1044 + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  1045 + PRIMARY KEY (`id`),
  1046 + UNIQUE KEY `uk_store_image` (`store_id`, `image_id`),
  1047 + KEY `idx_tenant_id` (`tenant_id`),
  1048 + KEY `idx_product_id` (`product_id`)
  1049 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠商品图片表';
  1050 +```
  1051 +
  1052 +#### 6.1.3 同步逻辑实现
  1053 +
  1054 +```java
  1055 +@Service
  1056 +public class ProductSyncService {
  1057 +
  1058 + @Autowired
  1059 + private ShoplazzaApiClient apiClient;
  1060 +
  1061 + @Autowired
  1062 + private ProductSpuMapper spuMapper;
  1063 +
  1064 + @Autowired
  1065 + private ProductSkuMapper skuMapper;
  1066 +
  1067 + @Autowired
  1068 + private ProductImageMapper imageMapper;
  1069 +
  1070 + /**
  1071 + * 同步单个店铺的所有商品
  1072 + */
  1073 + public void syncProducts(Long shopConfigId) {
  1074 + ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
  1075 + if (shop == null) {
  1076 + throw new BusinessException("Shop not found");
  1077 + }
  1078 +
  1079 + int page = 1;
  1080 + int limit = 50;
  1081 + boolean hasMore = true;
  1082 + int totalSynced = 0;
  1083 +
  1084 + while (hasMore) {
  1085 + try {
  1086 + // 调用 API 获取商品列表
  1087 + String endpoint = String.format(
  1088 + "https://%s.myshoplaza.com/openapi/2022-01/products?page=%d&limit=%d",
  1089 + shop.getStoreDomain().split("\\.")[0],
  1090 + page,
  1091 + limit
  1092 + );
  1093 +
  1094 + ProductListResponse response = apiClient.callApi(
  1095 + shop.getStoreId(),
  1096 + endpoint,
  1097 + ProductListResponse.class
  1098 + );
  1099 +
  1100 + if (response.getProducts() == null || response.getProducts().isEmpty()) {
  1101 + hasMore = false;
  1102 + break;
  1103 + }
  1104 +
  1105 + // 保存商品数据
  1106 + for (ProductDto product : response.getProducts()) {
  1107 + saveProduct(shop.getTenantId(), shop.getStoreId(), product);
  1108 + totalSynced++;
  1109 + }
  1110 +
  1111 + log.info("Synced page {} for shop {}, total: {}", page, shop.getStoreName(), totalSynced);
  1112 +
  1113 + // 下一页
  1114 + page++;
  1115 +
  1116 + // 避免触发速率限制
  1117 + Thread.sleep(100);
  1118 +
  1119 + } catch (Exception e) {
  1120 + log.error("Failed to sync products for shop: {}", shop.getStoreName(), e);
  1121 + throw new BusinessException("Product sync failed", e);
  1122 + }
  1123 + }
  1124 +
  1125 + // 更新最后同步时间
  1126 + shop.setLastSyncTime(new Date());
  1127 + shopConfigMapper.updateById(shop);
  1128 +
  1129 + log.info("Product sync completed for shop: {}, total synced: {}", shop.getStoreName(), totalSynced);
  1130 + }
  1131 +
  1132 + /**
  1133 + * 保存单个商品及其SKU和图片
  1134 + */
  1135 + @Transactional
  1136 + private void saveProduct(Long tenantId, String storeId, ProductDto product) {
  1137 + // 1. 保存 SPU
  1138 + ProductSpu spu = spuMapper.selectByStoreAndProductId(storeId, product.getId());
  1139 + if (spu == null) {
  1140 + spu = new ProductSpu();
  1141 + spu.setTenantId(tenantId);
  1142 + spu.setStoreId(storeId);
  1143 + spu.setProductId(product.getId());
  1144 + }
  1145 +
  1146 + spu.setTitle(product.getTitle());
  1147 + spu.setBodyHtml(product.getBodyHtml());
  1148 + spu.setVendor(product.getVendor());
  1149 + spu.setProductType(product.getProductType());
  1150 + spu.setHandle(product.getHandle());
  1151 + spu.setTags(product.getTags());
  1152 + spu.setStatus(product.getStatus());
  1153 + spu.setPublishedAt(product.getPublishedAt());
  1154 +
  1155 + if (spu.getId() == null) {
  1156 + spuMapper.insert(spu);
  1157 + } else {
  1158 + spuMapper.updateById(spu);
  1159 + }
  1160 +
  1161 + // 2. 保存 SKU
  1162 + if (product.getVariants() != null) {
  1163 + for (VariantDto variant : product.getVariants()) {
  1164 + ProductSku sku = skuMapper.selectByStoreAndVariantId(storeId, variant.getId());
  1165 + if (sku == null) {
  1166 + sku = new ProductSku();
  1167 + sku.setTenantId(tenantId);
  1168 + sku.setStoreId(storeId);
  1169 + sku.setProductId(product.getId());
  1170 + sku.setVariantId(variant.getId());
  1171 + }
  1172 +
  1173 + sku.setSku(variant.getSku());
  1174 + sku.setTitle(variant.getTitle());
  1175 + sku.setPrice(new BigDecimal(variant.getPrice()));
  1176 + sku.setCompareAtPrice(variant.getCompareAtPrice() != null ?
  1177 + new BigDecimal(variant.getCompareAtPrice()) : null);
  1178 + sku.setInventoryQuantity(variant.getInventoryQuantity());
  1179 + sku.setWeight(variant.getWeight());
  1180 + sku.setWeightUnit(variant.getWeightUnit());
  1181 + sku.setOption1(variant.getOption1());
  1182 + sku.setOption2(variant.getOption2());
  1183 + sku.setOption3(variant.getOption3());
  1184 +
  1185 + if (sku.getId() == null) {
  1186 + skuMapper.insert(sku);
  1187 + } else {
  1188 + skuMapper.updateById(sku);
  1189 + }
  1190 + }
  1191 + }
  1192 +
  1193 + // 3. 保存图片
  1194 + if (product.getImages() != null) {
  1195 + for (ImageDto image : product.getImages()) {
  1196 + ProductImage img = imageMapper.selectByStoreAndImageId(storeId, image.getId());
  1197 + if (img == null) {
  1198 + img = new ProductImage();
  1199 + img.setTenantId(tenantId);
  1200 + img.setStoreId(storeId);
  1201 + img.setProductId(product.getId());
  1202 + img.setImageId(image.getId());
  1203 + }
  1204 +
  1205 + img.setSrc(image.getSrc());
  1206 + img.setPosition(image.getPosition());
  1207 + img.setWidth(image.getWidth());
  1208 + img.setHeight(image.getHeight());
  1209 +
  1210 + if (img.getId() == null) {
  1211 + imageMapper.insert(img);
  1212 + } else {
  1213 + imageMapper.updateById(img);
  1214 + }
  1215 + }
  1216 + }
  1217 + }
  1218 +}
  1219 +```
  1220 +
  1221 +### 6.2 客户数据同步
  1222 +
  1223 +#### 6.2.1 数据表设计
  1224 +
  1225 +**客户表(shoplazza_customer):**
  1226 +
  1227 +```sql
  1228 +CREATE TABLE `shoplazza_customer` (
  1229 + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  1230 + `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  1231 + `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
  1232 + `customer_id` VARCHAR(64) NOT NULL COMMENT '店匠客户ID',
  1233 + `email` VARCHAR(255) DEFAULT NULL COMMENT '邮箱',
  1234 + `phone` VARCHAR(64) DEFAULT NULL COMMENT '电话',
  1235 + `first_name` VARCHAR(128) DEFAULT NULL COMMENT '名',
  1236 + `last_name` VARCHAR(128) DEFAULT NULL COMMENT '姓',
  1237 + `orders_count` INT DEFAULT 0 COMMENT '订单数量',
  1238 + `total_spent` DECIMAL(12,2) DEFAULT 0.00 COMMENT '累计消费',
  1239 + `state` VARCHAR(32) DEFAULT NULL COMMENT '状态',
  1240 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  1241 + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  1242 + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  1243 + PRIMARY KEY (`id`),
  1244 + UNIQUE KEY `uk_store_customer` (`store_id`, `customer_id`),
  1245 + KEY `idx_tenant_id` (`tenant_id`),
  1246 + KEY `idx_email` (`email`)
  1247 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠客户表';
  1248 +```
  1249 +
  1250 +**客户地址表(shoplazza_customer_address):**
  1251 +
  1252 +```sql
  1253 +CREATE TABLE `shoplazza_customer_address` (
  1254 + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  1255 + `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  1256 + `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
  1257 + `customer_id` VARCHAR(64) NOT NULL COMMENT '店匠客户ID',
  1258 + `address_id` VARCHAR(64) NOT NULL COMMENT '店匠地址ID',
  1259 + `first_name` VARCHAR(128) DEFAULT NULL COMMENT '名',
  1260 + `last_name` VARCHAR(128) DEFAULT NULL COMMENT '姓',
  1261 + `address1` VARCHAR(512) DEFAULT NULL COMMENT '地址行1',
  1262 + `address2` VARCHAR(512) DEFAULT NULL COMMENT '地址行2',
  1263 + `city` VARCHAR(128) DEFAULT NULL COMMENT '城市',
  1264 + `province` VARCHAR(128) DEFAULT NULL COMMENT '省份',
  1265 + `country` VARCHAR(128) DEFAULT NULL COMMENT '国家',
  1266 + `zip` VARCHAR(32) DEFAULT NULL COMMENT '邮编',
  1267 + `phone` VARCHAR(64) DEFAULT NULL COMMENT '电话',
  1268 + `is_default` BIT(1) DEFAULT b'0' COMMENT '是否默认地址',
  1269 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  1270 + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  1271 + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  1272 + PRIMARY KEY (`id`),
  1273 + UNIQUE KEY `uk_store_address` (`store_id`, `address_id`),
  1274 + KEY `idx_tenant_id` (`tenant_id`),
  1275 + KEY `idx_customer_id` (`customer_id`)
  1276 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠客户地址表';
  1277 +```
  1278 +
  1279 +#### 6.2.2 API 调用示例
  1280 +
  1281 +```bash
  1282 +curl --request GET \
  1283 + --url 'https://47167113-1.myshoplaza.com/openapi/2022-01/customers?page=1&limit=50' \
  1284 + --header 'access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY' \
  1285 + --header 'accept: application/json'
  1286 +```
  1287 +
  1288 +### 6.3 订单数据同步
  1289 +
  1290 +#### 6.3.1 数据表设计
  1291 +
  1292 +**订单表(shoplazza_order):**
  1293 +
  1294 +```sql
  1295 +CREATE TABLE `shoplazza_order` (
  1296 + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  1297 + `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  1298 + `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
  1299 + `order_id` VARCHAR(64) NOT NULL COMMENT '店匠订单ID',
  1300 + `order_number` VARCHAR(128) NOT NULL COMMENT '订单号',
  1301 + `customer_id` VARCHAR(64) DEFAULT NULL COMMENT '客户ID',
  1302 + `email` VARCHAR(255) DEFAULT NULL COMMENT '客户邮箱',
  1303 + `total_price` DECIMAL(12,2) NOT NULL COMMENT '订单总价',
  1304 + `subtotal_price` DECIMAL(12,2) DEFAULT NULL COMMENT '小计',
  1305 + `total_tax` DECIMAL(12,2) DEFAULT NULL COMMENT '税费',
  1306 + `total_shipping` DECIMAL(12,2) DEFAULT NULL COMMENT '运费',
  1307 + `currency` VARCHAR(16) DEFAULT 'USD' COMMENT '货币',
  1308 + `financial_status` VARCHAR(32) DEFAULT NULL COMMENT '支付状态',
  1309 + `fulfillment_status` VARCHAR(32) DEFAULT NULL COMMENT '配送状态',
  1310 + `order_status` VARCHAR(32) DEFAULT NULL COMMENT '订单状态',
  1311 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  1312 + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  1313 + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  1314 + PRIMARY KEY (`id`),
  1315 + UNIQUE KEY `uk_store_order` (`store_id`, `order_id`),
  1316 + KEY `idx_tenant_id` (`tenant_id`),
  1317 + KEY `idx_customer_id` (`customer_id`),
  1318 + KEY `idx_order_number` (`order_number`)
  1319 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠订单表';
  1320 +```
  1321 +
  1322 +**订单明细表(shoplazza_order_item):**
  1323 +
  1324 +```sql
  1325 +CREATE TABLE `shoplazza_order_item` (
  1326 + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  1327 + `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  1328 + `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
  1329 + `order_id` VARCHAR(64) NOT NULL COMMENT '店匠订单ID',
  1330 + `line_item_id` VARCHAR(64) NOT NULL COMMENT '店匠明细ID',
  1331 + `product_id` VARCHAR(64) DEFAULT NULL COMMENT '商品ID',
  1332 + `variant_id` VARCHAR(64) DEFAULT NULL COMMENT '变体ID',
  1333 + `sku` VARCHAR(255) DEFAULT NULL COMMENT 'SKU',
  1334 + `title` VARCHAR(512) DEFAULT NULL COMMENT '商品标题',
  1335 + `quantity` INT NOT NULL COMMENT '数量',
  1336 + `price` DECIMAL(12,2) NOT NULL COMMENT '单价',
  1337 + `total_price` DECIMAL(12,2) NOT NULL COMMENT '总价',
  1338 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  1339 + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  1340 + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
  1341 + PRIMARY KEY (`id`),
  1342 + UNIQUE KEY `uk_store_line_item` (`store_id`, `line_item_id`),
  1343 + KEY `idx_tenant_id` (`tenant_id`),
  1344 + KEY `idx_order_id` (`order_id`),
  1345 + KEY `idx_product_id` (`product_id`)
  1346 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠订单明细表';
  1347 +```
  1348 +
  1349 +### 6.4 同步调度策略
  1350 +
  1351 +#### 6.4.1 首次全量同步
  1352 +
  1353 +商家安装 APP 后,触发首次全量同步:
  1354 +
  1355 +```java
  1356 +@Service
  1357 +public class DataSyncService {
  1358 +
  1359 + @Async
  1360 + public void syncAllData(Long shopConfigId) {
  1361 + log.info("Starting full data sync for shop: {}", shopConfigId);
  1362 +
  1363 + try {
  1364 + // 1. 同步商品(优先级最高)
  1365 + productSyncService.syncProducts(shopConfigId);
  1366 +
  1367 + // 2. 同步客户
  1368 + customerSyncService.syncCustomers(shopConfigId);
  1369 +
  1370 + // 3. 同步订单
  1371 + orderSyncService.syncOrders(shopConfigId);
  1372 +
  1373 + // 4. 注册 Webhook
  1374 + webhookService.registerWebhooks(shopConfigId);
  1375 +
  1376 + // 5. 索引商品到 ES
  1377 + esIndexService.indexProducts(shopConfigId);
  1378 +
  1379 + log.info("Full data sync completed for shop: {}", shopConfigId);
  1380 +
  1381 + } catch (Exception e) {
  1382 + log.error("Full data sync failed for shop: {}", shopConfigId, e);
  1383 + // 可选:发送告警通知
  1384 + }
  1385 + }
  1386 +}
  1387 +```
  1388 +
  1389 +#### 6.4.2 定时增量同步
  1390 +
  1391 +配置定时任务,定期同步数据:
  1392 +
  1393 +```java
  1394 +@Component
  1395 +public class ScheduledSyncTask {
  1396 +
  1397 + @Autowired
  1398 + private DataSyncService dataSyncService;
  1399 +
  1400 + @Autowired
  1401 + private ShopConfigMapper shopConfigMapper;
  1402 +
  1403 + /**
  1404 + * 每小时同步一次商品数据
  1405 + */
  1406 + @Scheduled(cron = "0 0 * * * ?")
  1407 + public void syncProductsHourly() {
  1408 + List<ShopConfig> activeShops = shopConfigMapper.selectActiveShops();
  1409 +
  1410 + for (ShopConfig shop : activeShops) {
  1411 + try {
  1412 + productSyncService.syncProducts(shop.getId());
  1413 + } catch (Exception e) {
  1414 + log.error("Scheduled product sync failed for shop: {}", shop.getStoreName(), e);
  1415 + }
  1416 + }
  1417 + }
  1418 +
  1419 + /**
  1420 + * 每天同步一次客户和订单数据
  1421 + */
  1422 + @Scheduled(cron = "0 0 3 * * ?")
  1423 + public void syncCustomersAndOrdersDaily() {
  1424 + List<ShopConfig> activeShops = shopConfigMapper.selectActiveShops();
  1425 +
  1426 + for (ShopConfig shop : activeShops) {
  1427 + try {
  1428 + customerSyncService.syncCustomers(shop.getId());
  1429 + orderSyncService.syncOrders(shop.getId());
  1430 + } catch (Exception e) {
  1431 + log.error("Scheduled sync failed for shop: {}", shop.getStoreName(), e);
  1432 + }
  1433 + }
  1434 + }
  1435 +}
  1436 +```
  1437 +
  1438 +#### 6.4.3 失败重试机制
  1439 +
  1440 +使用 Spring Retry 实现失败重试:
  1441 +
  1442 +```java
  1443 +@Service
  1444 +public class RobustSyncService {
  1445 +
  1446 + @Retryable(
  1447 + value = {ApiException.class, HttpClientErrorException.class},
  1448 + maxAttempts = 3,
  1449 + backoff = @Backoff(delay = 2000, multiplier = 2)
  1450 + )
  1451 + public void syncWithRetry(Long shopConfigId, String syncType) {
  1452 + switch (syncType) {
  1453 + case "products":
  1454 + productSyncService.syncProducts(shopConfigId);
  1455 + break;
  1456 + case "customers":
  1457 + customerSyncService.syncCustomers(shopConfigId);
  1458 + break;
  1459 + case "orders":
  1460 + orderSyncService.syncOrders(shopConfigId);
  1461 + break;
  1462 + default:
  1463 + throw new IllegalArgumentException("Unknown sync type: " + syncType);
  1464 + }
  1465 + }
  1466 +
  1467 + @Recover
  1468 + public void recoverFromSyncFailure(Exception e, Long shopConfigId, String syncType) {
  1469 + log.error("Sync failed after retries: shop={}, type={}", shopConfigId, syncType, e);
  1470 + // 记录失败日志,发送告警
  1471 + alertService.sendAlert("Data sync failed",
  1472 + String.format("Shop: %d, Type: %s, Error: %s", shopConfigId, syncType, e.getMessage()));
  1473 + }
  1474 +}
  1475 +```
  1476 +
  1477 +---
  1478 +
  1479 +## 7. Webhook 集成
  1480 +
  1481 +### 7.1 Webhook 概述
  1482 +
  1483 +Webhook 是店匠平台的事件通知机制,当店铺发生特定事件(如商品更新、订单创建)时,店匠会主动向你注册的 Webhook 地址发送 HTTP POST 请求,实现实时数据同步。
  1484 +
  1485 +**优势:**
  1486 +- ✅ 实时性:事件发生后立即通知
  1487 +- ✅ 减少 API 调用:避免频繁轮询
  1488 +- ✅ 精准更新:只更新变化的数据
  1489 +
  1490 +### 7.2 支持的 Webhook Topic
  1491 +
  1492 +店匠支持以下 Webhook 事件类型:
  1493 +
  1494 +#### 7.2.1 商品相关
  1495 +
  1496 +| Topic | 说明 | 触发时机 |
  1497 +|-------|------|----------|
  1498 +| `products/create` | 商品创建 | 商家创建新商品时 |
  1499 +| `products/update` | 商品更新 | 商家修改商品信息时 |
  1500 +| `products/delete` | 商品删除 | 商家删除商品时 |
  1501 +
  1502 +#### 7.2.2 订单相关
  1503 +
  1504 +| Topic | 说明 | 触发时机 |
  1505 +|-------|------|----------|
  1506 +| `orders/create` | 订单创建 | 买家下单时 |
  1507 +| `orders/updated` | 订单更新 | 订单状态变化时 |
  1508 +| `orders/paid` | 订单支付 | 订单支付成功时 |
  1509 +| `orders/cancelled` | 订单取消 | 订单被取消时 |
  1510 +
  1511 +#### 7.2.3 客户相关
  1512 +
  1513 +| Topic | 说明 | 触发时机 |
  1514 +|-------|------|----------|
  1515 +| `customers/create` | 客户创建 | 新客户注册时 |
  1516 +| `customers/update` | 客户更新 | 客户信息更新时 |
  1517 +| `customers/delete` | 客户删除 | 客户被删除时 |
  1518 +
  1519 +### 7.3 注册 Webhook
  1520 +
  1521 +#### 7.3.1 API 调用
  1522 +
  1523 +店铺激活后,自动注册所需的 Webhook:
  1524 +
  1525 +```bash
  1526 +curl --request POST \
  1527 + --url 'https://47167113-1.myshoplaza.com/openapi/2022-01/webhooks' \
  1528 + --header 'access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY' \
  1529 + --header 'accept: application/json' \
  1530 + --header 'content-type: application/json' \
  1531 + --data '{
  1532 + "address": "https://your-domain.com/webhook/shoplazza",
  1533 + "topic": "products/update"
  1534 + }'
  1535 +```
  1536 +
  1537 +**响应示例:**
  1538 +
  1539 +```json
  1540 +{
  1541 + "webhook": {
  1542 + "id": "123456",
  1543 + "address": "https://your-domain.com/webhook/shoplazza",
  1544 + "topic": "products/update",
  1545 + "created_at": "2024-01-15T10:00:00Z",
  1546 + "updated_at": "2024-01-15T10:00:00Z"
  1547 + }
  1548 +}
  1549 +```
  1550 +
  1551 +#### 7.3.2 批量注册实现
  1552 +
  1553 +```java
  1554 +@Service
  1555 +public class WebhookService {
  1556 +
  1557 + private static final List<String> WEBHOOK_TOPICS = Arrays.asList(
  1558 + "products/create",
  1559 + "products/update",
  1560 + "products/delete",
  1561 + "orders/create",
  1562 + "orders/updated",
  1563 + "orders/paid",
  1564 + "customers/create",
  1565 + "customers/update"
  1566 + );
  1567 +
  1568 + /**
  1569 + * 为店铺注册所有 Webhook
  1570 + */
  1571 + public void registerWebhooks(Long shopConfigId) {
  1572 + ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
  1573 + if (shop == null) {
  1574 + throw new BusinessException("Shop not found");
  1575 + }
  1576 +
  1577 + String webhookUrl = buildWebhookUrl(shop.getStoreId());
  1578 +
  1579 + for (String topic : WEBHOOK_TOPICS) {
  1580 + try {
  1581 + registerSingleWebhook(shop, webhookUrl, topic);
  1582 + log.info("Registered webhook for shop: {}, topic: {}", shop.getStoreName(), topic);
  1583 + } catch (Exception e) {
  1584 + log.error("Failed to register webhook: shop={}, topic={}", shop.getStoreName(), topic, e);
  1585 + // 继续注册其他 Webhook
  1586 + }
  1587 + }
  1588 + }
  1589 +
  1590 + private void registerSingleWebhook(ShopConfig shop, String webhookUrl, String topic) {
  1591 + String endpoint = String.format(
  1592 + "https://%s/openapi/2022-01/webhooks",
  1593 + shop.getStoreDomain()
  1594 + );
  1595 +
  1596 + WebhookRequest request = new WebhookRequest();
  1597 + request.setAddress(webhookUrl);
  1598 + request.setTopic(topic);
  1599 +
  1600 + apiClient.post(shop.getStoreId(), endpoint, request, WebhookResponse.class);
  1601 + }
  1602 +
  1603 + private String buildWebhookUrl(String storeId) {
  1604 + return String.format("%s/webhook/shoplazza/%s",
  1605 + appConfig.getBaseUrl(),
  1606 + storeId);
  1607 + }
  1608 +}
  1609 +```
  1610 +
  1611 +### 7.4 接收和处理 Webhook
  1612 +
  1613 +#### 7.4.1 Webhook 请求格式
  1614 +
  1615 +店匠发送的 Webhook 请求格式:
  1616 +
  1617 +```http
  1618 +POST /webhook/shoplazza/{store_id}
  1619 +Content-Type: application/json
  1620 +X-Shoplazza-Hmac-Sha256: {signature}
  1621 +X-Shoplazza-Topic: products/update
  1622 +X-Shoplazza-Shop-Domain: 47167113-1.myshoplaza.com
  1623 +
  1624 +{
  1625 + "id": "193817395",
  1626 + "title": "蓝牙耳机",
  1627 + "variants": [...],
  1628 + "images": [...],
  1629 + ...
  1630 +}
  1631 +```
  1632 +
  1633 +**请求头说明:**
  1634 +- `X-Shoplazza-Hmac-Sha256`:HMAC-SHA256 签名(用于验证请求真实性)
  1635 +- `X-Shoplazza-Topic`:事件类型
  1636 +- `X-Shoplazza-Shop-Domain`:店铺域名
  1637 +
  1638 +#### 7.4.2 签名验证
  1639 +
  1640 +为了确保 Webhook 请求来自店匠平台,需要验证签名:
  1641 +
  1642 +```java
  1643 +@RestController
  1644 +@RequestMapping("/webhook/shoplazza")
  1645 +public class WebhookController {
  1646 +
  1647 + @Autowired
  1648 + private WebhookService webhookService;
  1649 +
  1650 + @PostMapping("/{storeId}")
  1651 + public ResponseEntity<String> handleWebhook(
  1652 + @PathVariable String storeId,
  1653 + @RequestHeader("X-Shoplazza-Hmac-Sha256") String signature,
  1654 + @RequestHeader("X-Shoplazza-Topic") String topic,
  1655 + @RequestHeader("X-Shoplazza-Shop-Domain") String shopDomain,
  1656 + @RequestBody String payload) {
  1657 +
  1658 + try {
  1659 + // 1. 验证签名
  1660 + if (!webhookService.verifySignature(storeId, payload, signature)) {
  1661 + log.warn("Invalid webhook signature: store={}, topic={}", storeId, topic);
  1662 + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid signature");
  1663 + }
  1664 +
  1665 + // 2. 处理事件(异步)
  1666 + webhookService.processWebhookAsync(storeId, topic, payload);
  1667 +
  1668 + // 3. 立即返回 200(店匠要求3秒内响应)
  1669 + return ResponseEntity.ok("OK");
  1670 +
  1671 + } catch (Exception e) {
  1672 + log.error("Failed to handle webhook: store={}, topic={}", storeId, topic, e);
  1673 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error");
  1674 + }
  1675 + }
  1676 +}
  1677 +
  1678 +@Service
  1679 +public class WebhookService {
  1680 +
  1681 + /**
  1682 + * 验证 Webhook 签名
  1683 + */
  1684 + public boolean verifySignature(String storeId, String payload, String signature) {
  1685 + ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
  1686 + if (shop == null) {
  1687 + return false;
  1688 + }
  1689 +
  1690 + // 使用 Client Secret 作为签名密钥
  1691 + String clientSecret = appConfig.getClientSecret();
  1692 +
  1693 + try {
  1694 + Mac mac = Mac.getInstance("HmacSHA256");
  1695 + SecretKeySpec secretKey = new SecretKeySpec(
  1696 + clientSecret.getBytes(StandardCharsets.UTF_8),
  1697 + "HmacSHA256"
  1698 + );
  1699 + mac.init(secretKey);
  1700 +
  1701 + byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
  1702 + String computedSignature = Base64.getEncoder().encodeToString(hash);
  1703 +
  1704 + return computedSignature.equals(signature);
  1705 +
  1706 + } catch (Exception e) {
  1707 + log.error("Failed to verify signature", e);
  1708 + return false;
  1709 + }
  1710 + }
  1711 +
  1712 + /**
  1713 + * 异步处理 Webhook 事件
  1714 + */
  1715 + @Async
  1716 + public void processWebhookAsync(String storeId, String topic, String payload) {
  1717 + try {
  1718 + log.info("Processing webhook: store={}, topic={}", storeId, topic);
  1719 +
  1720 + switch (topic) {
  1721 + case "products/create":
  1722 + case "products/update":
  1723 + handleProductUpdate(storeId, payload);
  1724 + break;
  1725 + case "products/delete":
  1726 + handleProductDelete(storeId, payload);
  1727 + break;
  1728 + case "orders/create":
  1729 + case "orders/updated":
  1730 + case "orders/paid":
  1731 + handleOrderUpdate(storeId, payload);
  1732 + break;
  1733 + case "orders/cancelled":
  1734 + handleOrderCancel(storeId, payload);
  1735 + break;
  1736 + case "customers/create":
  1737 + case "customers/update":
  1738 + handleCustomerUpdate(storeId, payload);
  1739 + break;
  1740 + case "customers/delete":
  1741 + handleCustomerDelete(storeId, payload);
  1742 + break;
  1743 + default:
  1744 + log.warn("Unknown webhook topic: {}", topic);
  1745 + }
  1746 +
  1747 + } catch (Exception e) {
  1748 + log.error("Failed to process webhook: store={}, topic={}", storeId, topic, e);
  1749 + }
  1750 + }
  1751 +
  1752 + private void handleProductUpdate(String storeId, String payload) {
  1753 + ProductDto product = JSON.parseObject(payload, ProductDto.class);
  1754 + ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
  1755 +
  1756 + // 更新数据库
  1757 + productSyncService.saveProduct(shop.getTenantId(), storeId, product);
  1758 +
  1759 + // 更新 ES 索引
  1760 + esIndexService.indexSingleProduct(shop.getTenantId(), product.getId());
  1761 + }
  1762 +
  1763 + private void handleProductDelete(String storeId, String payload) {
  1764 + ProductDto product = JSON.parseObject(payload, ProductDto.class);
  1765 + ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
  1766 +
  1767 + // 软删除数据库记录
  1768 + productSpuMapper.softDeleteByProductId(storeId, product.getId());
  1769 +
  1770 + // 从 ES 中删除
  1771 + esIndexService.deleteProduct(shop.getTenantId(), product.getId());
  1772 + }
  1773 +
  1774 + // ... 其他事件处理方法
  1775 +}
  1776 +```
  1777 +
  1778 +### 7.5 幂等性保证
  1779 +
  1780 +为了避免重复处理同一个事件,需要实现幂等性:
  1781 +
  1782 +```java
  1783 +@Service
  1784 +public class WebhookEventService {
  1785 +
  1786 + @Autowired
  1787 + private RedisTemplate<String, String> redisTemplate;
  1788 +
  1789 + /**
  1790 + * 检查事件是否已处理(使用 Redis 去重)
  1791 + */
  1792 + public boolean isEventProcessed(String storeId, String topic, String eventId) {
  1793 + String key = String.format("webhook:processed:%s:%s:%s", storeId, topic, eventId);
  1794 + return Boolean.TRUE.equals(redisTemplate.hasKey(key));
  1795 + }
  1796 +
  1797 + /**
  1798 + * 标记事件已处理(保留24小时)
  1799 + */
  1800 + public void markEventProcessed(String storeId, String topic, String eventId) {
  1801 + String key = String.format("webhook:processed:%s:%s:%s", storeId, topic, eventId);
  1802 + redisTemplate.opsForValue().set(key, "1", 24, TimeUnit.HOURS);
  1803 + }
  1804 +
  1805 + /**
  1806 + * 处理事件(带幂等性保证)
  1807 + */
  1808 + @Transactional
  1809 + public void processEventIdempotent(String storeId, String topic, String eventId, Runnable handler) {
  1810 + // 检查是否已处理
  1811 + if (isEventProcessed(storeId, topic, eventId)) {
  1812 + log.info("Event already processed: store={}, topic={}, eventId={}", storeId, topic, eventId);
  1813 + return;
  1814 + }
  1815 +
  1816 + // 处理事件
  1817 + handler.run();
  1818 +
  1819 + // 标记已处理
  1820 + markEventProcessed(storeId, topic, eventId);
  1821 + }
  1822 +}
  1823 +```
  1824 +
  1825 +---
  1826 +
  1827 +## 8. Elasticsearch 索引
  1828 +
  1829 +### 8.1 索引结构设计
  1830 +
  1831 +基于店匠商品结构,设计 Elasticsearch mapping:
  1832 +
  1833 +```json
  1834 +{
  1835 + "settings": {
  1836 + "number_of_shards": 3,
  1837 + "number_of_replicas": 1,
  1838 + "analysis": {
  1839 + "analyzer": {
  1840 + "chinese_ecommerce": {
  1841 + "type": "custom",
  1842 + "tokenizer": "ik_max_word",
  1843 + "filter": ["lowercase"]
  1844 + }
  1845 + }
  1846 + }
  1847 + },
  1848 + "mappings": {
  1849 + "properties": {
  1850 + "tenant_id": {
  1851 + "type": "keyword"
  1852 + },
  1853 + "store_id": {
  1854 + "type": "keyword"
  1855 + },
  1856 + "product_id": {
  1857 + "type": "keyword"
  1858 + },
  1859 + "title": {
  1860 + "type": "text",
  1861 + "analyzer": "chinese_ecommerce",
  1862 + "fields": {
  1863 + "keyword": {
  1864 + "type": "keyword"
  1865 + },
  1866 + "en": {
  1867 + "type": "text",
  1868 + "analyzer": "english"
  1869 + }
  1870 + }
  1871 + },
  1872 + "title_embedding": {
  1873 + "type": "dense_vector",
  1874 + "dims": 1024,
  1875 + "index": true,
  1876 + "similarity": "cosine"
  1877 + },
  1878 + "body_html": {
  1879 + "type": "text",
  1880 + "analyzer": "chinese_ecommerce"
  1881 + },
  1882 + "vendor": {
  1883 + "type": "keyword"
  1884 + },
  1885 + "product_type": {
  1886 + "type": "keyword"
  1887 + },
  1888 + "tags": {
  1889 + "type": "keyword"
  1890 + },
  1891 + "price": {
  1892 + "type": "float"
  1893 + },
  1894 + "compare_at_price": {
  1895 + "type": "float"
  1896 + },
  1897 + "inventory_quantity": {
  1898 + "type": "integer"
  1899 + },
  1900 + "image_url": {
  1901 + "type": "keyword",
  1902 + "index": false
  1903 + },
  1904 + "image_embedding": {
  1905 + "type": "dense_vector",
  1906 + "dims": 1024,
  1907 + "index": true,
  1908 + "similarity": "cosine"
  1909 + },
  1910 + "variants": {
  1911 + "type": "nested",
  1912 + "properties": {
  1913 + "variant_id": {"type": "keyword"},
  1914 + "sku": {"type": "keyword"},
  1915 + "title": {"type": "text", "analyzer": "chinese_ecommerce"},
  1916 + "price": {"type": "float"},
  1917 + "inventory_quantity": {"type": "integer"},
  1918 + "option1": {"type": "keyword"},
  1919 + "option2": {"type": "keyword"},
  1920 + "option3": {"type": "keyword"}
  1921 + }
  1922 + },
  1923 + "status": {
  1924 + "type": "keyword"
  1925 + },
  1926 + "created_at": {
  1927 + "type": "date"
  1928 + },
  1929 + "updated_at": {
  1930 + "type": "date"
  1931 + }
  1932 + }
  1933 + }
  1934 +}
  1935 +```
  1936 +
  1937 +### 8.2 索引命名规范
  1938 +
  1939 +使用租户隔离的索引命名:
  1940 +
  1941 +```
  1942 +shoplazza_products_{tenant_id}
  1943 +```
  1944 +
  1945 +例如:
  1946 +- `shoplazza_products_1`
  1947 +- `shoplazza_products_2`
  1948 +
  1949 +### 8.3 数据索引流程
  1950 +
  1951 +#### 8.3.1 从数据库读取商品
  1952 +
  1953 +```java
  1954 +@Service
  1955 +public class EsIndexService {
  1956 +
  1957 + @Autowired
  1958 + private ProductSpuMapper spuMapper;
  1959 +
  1960 + @Autowired
  1961 + private ProductSkuMapper skuMapper;
  1962 +
  1963 + @Autowired
  1964 + private ProductImageMapper imageMapper;
  1965 +
  1966 + @Autowired
  1967 + private EmbeddingService embeddingService;
  1968 +
  1969 + @Autowired
  1970 + private RestHighLevelClient esClient;
  1971 +
  1972 + /**
  1973 + * 为店铺的所有商品建立索引
  1974 + */
  1975 + public void indexProducts(Long shopConfigId) {
  1976 + ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
  1977 + if (shop == null) {
  1978 + throw new BusinessException("Shop not found");
  1979 + }
  1980 +
  1981 + String indexName = String.format("shoplazza_products_%d", shop.getTenantId());
  1982 +
  1983 + // 1. 创建索引(如果不存在)
  1984 + createIndexIfNotExists(indexName);
  1985 +
  1986 + // 2. 查询所有商品
  1987 + List<ProductSpu> products = spuMapper.selectByStoreId(shop.getStoreId());
  1988 +
  1989 + // 3. 批量索引
  1990 + BulkRequest bulkRequest = new BulkRequest();
  1991 +
  1992 + for (ProductSpu spu : products) {
  1993 + try {
  1994 + // 构建 ES 文档
  1995 + Map<String, Object> doc = buildEsDocument(shop.getTenantId(), spu);
  1996 +
  1997 + // 添加到批量请求
  1998 + IndexRequest indexRequest = new IndexRequest(indexName)
  1999 + .id(spu.getProductId())
  2000 + .source(doc);
  2001 + bulkRequest.add(indexRequest);
  2002 +
  2003 + // 每500条提交一次
  2004 + if (bulkRequest.numberOfActions() >= 500) {
  2005 + BulkResponse bulkResponse = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
  2006 + if (bulkResponse.hasFailures()) {
  2007 + log.error("Bulk index has failures: {}", bulkResponse.buildFailureMessage());
  2008 + }
  2009 + bulkRequest = new BulkRequest();
  2010 + }
  2011 +
  2012 + } catch (Exception e) {
  2013 + log.error("Failed to index product: {}", spu.getProductId(), e);
  2014 + }
  2015 + }
  2016 +
  2017 + // 4. 提交剩余的文档
  2018 + if (bulkRequest.numberOfActions() > 0) {
  2019 + BulkResponse bulkResponse = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
  2020 + if (bulkResponse.hasFailures()) {
  2021 + log.error("Bulk index has failures: {}", bulkResponse.buildFailureMessage());
  2022 + }
  2023 + }
  2024 +
  2025 + log.info("Indexed {} products for shop: {}", products.size(), shop.getStoreName());
  2026 + }
  2027 +
  2028 + /**
  2029 + * 构建 ES 文档
  2030 + */
  2031 + private Map<String, Object> buildEsDocument(Long tenantId, ProductSpu spu) {
  2032 + Map<String, Object> doc = new HashMap<>();
  2033 +
  2034 + // 基本字段
  2035 + doc.put("tenant_id", tenantId.toString());
  2036 + doc.put("store_id", spu.getStoreId());
  2037 + doc.put("product_id", spu.getProductId());
  2038 + doc.put("title", spu.getTitle());
  2039 + doc.put("body_html", spu.getBodyHtml());
  2040 + doc.put("vendor", spu.getVendor());
  2041 + doc.put("product_type", spu.getProductType());
  2042 + doc.put("status", spu.getStatus());
  2043 + doc.put("created_at", spu.getCreatedAt());
  2044 + doc.put("updated_at", spu.getUpdatedAt());
  2045 +
  2046 + // 标签
  2047 + if (StringUtils.isNotEmpty(spu.getTags())) {
  2048 + doc.put("tags", Arrays.asList(spu.getTags().split(",")));
  2049 + }
  2050 +
  2051 + // 变体(SKU)
  2052 + List<ProductSku> skus = skuMapper.selectByProductId(spu.getProductId());
  2053 + if (CollectionUtils.isNotEmpty(skus)) {
  2054 + List<Map<String, Object>> variants = new ArrayList<>();
  2055 + for (ProductSku sku : skus) {
  2056 + Map<String, Object> variant = new HashMap<>();
  2057 + variant.put("variant_id", sku.getVariantId());
  2058 + variant.put("sku", sku.getSku());
  2059 + variant.put("title", sku.getTitle());
  2060 + variant.put("price", sku.getPrice());
  2061 + variant.put("inventory_quantity", sku.getInventoryQuantity());
  2062 + variant.put("option1", sku.getOption1());
  2063 + variant.put("option2", sku.getOption2());
  2064 + variant.put("option3", sku.getOption3());
  2065 + variants.add(variant);
  2066 + }
  2067 + doc.put("variants", variants);
  2068 +
  2069 + // 使用第一个 SKU 的价格和库存
  2070 + ProductSku firstSku = skus.get(0);
  2071 + doc.put("price", firstSku.getPrice());
  2072 + doc.put("inventory_quantity", firstSku.getInventoryQuantity());
  2073 + }
  2074 +
  2075 + // 图片
  2076 + List<ProductImage> images = imageMapper.selectByProductId(spu.getProductId());
  2077 + if (CollectionUtils.isNotEmpty(images)) {
  2078 + ProductImage firstImage = images.get(0);
  2079 + doc.put("image_url", firstImage.getSrc());
  2080 +
  2081 + // 生成图片向量
  2082 + try {
  2083 + float[] imageEmbedding = embeddingService.encodeImage(firstImage.getSrc());
  2084 + doc.put("image_embedding", imageEmbedding);
  2085 + } catch (Exception e) {
  2086 + log.warn("Failed to encode image: {}", firstImage.getSrc(), e);
  2087 + }
  2088 + }
  2089 +
  2090 + // 生成标题向量
  2091 + try {
  2092 + float[] titleEmbedding = embeddingService.encodeText(spu.getTitle());
  2093 + doc.put("title_embedding", titleEmbedding);
  2094 + } catch (Exception e) {
  2095 + log.warn("Failed to encode title: {}", spu.getTitle(), e);
  2096 + }
  2097 +
  2098 + return doc;
  2099 + }
  2100 +}
  2101 +```
  2102 +
  2103 +#### 8.3.2 调用 Python 向量服务
  2104 +
  2105 +向量生成需要调用 Python 服务:
  2106 +
  2107 +```java
  2108 +@Service
  2109 +public class EmbeddingService {
  2110 +
  2111 + @Autowired
  2112 + private RestTemplate restTemplate;
  2113 +
  2114 + @Value("${embedding.service.url}")
  2115 + private String embeddingServiceUrl;
  2116 +
  2117 + /**
  2118 + * 生成文本向量
  2119 + */
  2120 + public float[] encodeText(String text) {
  2121 + try {
  2122 + String url = embeddingServiceUrl + "/encode/text";
  2123 +
  2124 + Map<String, String> request = new HashMap<>();
  2125 + request.put("text", text);
  2126 +
  2127 + ResponseEntity<EmbeddingResponse> response = restTemplate.postForEntity(
  2128 + url,
  2129 + request,
  2130 + EmbeddingResponse.class
  2131 + );
  2132 +
  2133 + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
  2134 + return response.getBody().getEmbedding();
  2135 + }
  2136 +
  2137 + throw new BusinessException("Failed to encode text");
  2138 +
  2139 + } catch (Exception e) {
  2140 + log.error("Failed to call embedding service", e);
  2141 + throw new BusinessException("Embedding service error", e);
  2142 + }
  2143 + }
  2144 +
  2145 + /**
  2146 + * 生成图片向量
  2147 + */
  2148 + public float[] encodeImage(String imageUrl) {
  2149 + try {
  2150 + String url = embeddingServiceUrl + "/encode/image";
  2151 +
  2152 + Map<String, String> request = new HashMap<>();
  2153 + request.put("image_url", imageUrl);
  2154 +
  2155 + ResponseEntity<EmbeddingResponse> response = restTemplate.postForEntity(
  2156 + url,
  2157 + request,
  2158 + EmbeddingResponse.class
  2159 + );
  2160 +
  2161 + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
  2162 + return response.getBody().getEmbedding();
  2163 + }
  2164 +
  2165 + throw new BusinessException("Failed to encode image");
  2166 +
  2167 + } catch (Exception e) {
  2168 + log.error("Failed to call embedding service", e);
  2169 + throw new BusinessException("Embedding service error", e);
  2170 + }
  2171 + }
  2172 +}
  2173 +```
  2174 +
  2175 +### 8.4 增量索引更新
  2176 +
  2177 +Webhook 触发增量更新:
  2178 +
  2179 +```java
  2180 +public void indexSingleProduct(Long tenantId, String productId) {
  2181 + String indexName = String.format("shoplazza_products_%d", tenantId);
  2182 +
  2183 + ProductSpu spu = spuMapper.selectByProductId(productId);
  2184 + if (spu == null) {
  2185 + log.warn("Product not found: {}", productId);
  2186 + return;
  2187 + }
  2188 +
  2189 + try {
  2190 + // 构建文档
  2191 + Map<String, Object> doc = buildEsDocument(tenantId, spu);
  2192 +
  2193 + // 索引文档
  2194 + IndexRequest request = new IndexRequest(indexName)
  2195 + .id(productId)
  2196 + .source(doc);
  2197 +
  2198 + esClient.index(request, RequestOptions.DEFAULT);
  2199 +
  2200 + log.info("Indexed product: {}", productId);
  2201 +
  2202 + } catch (Exception e) {
  2203 + log.error("Failed to index product: {}", productId, e);
  2204 + }
  2205 +}
  2206 +
  2207 +public void deleteProduct(Long tenantId, String productId) {
  2208 + String indexName = String.format("shoplazza_products_%d", tenantId);
  2209 +
  2210 + try {
  2211 + DeleteRequest request = new DeleteRequest(indexName, productId);
  2212 + esClient.delete(request, RequestOptions.DEFAULT);
  2213 +
  2214 + log.info("Deleted product from ES: {}", productId);
  2215 +
  2216 + } catch (Exception e) {
  2217 + log.error("Failed to delete product from ES: {}", productId, e);
  2218 + }
  2219 +}
  2220 +```
  2221 +
  2222 +---
  2223 +
  2224 +## 9. 搜索服务集成
  2225 +
  2226 +### 9.1 搜索 API 调用
  2227 +
  2228 +Java 后端接收前端搜索请求后,转发给 Python 搜索服务:
  2229 +
  2230 +```java
  2231 +@RestController
  2232 +@RequestMapping("/api/search")
  2233 +public class SearchController {
  2234 +
  2235 + @Autowired
  2236 + private SearchService searchService;
  2237 +
  2238 + @PostMapping("/products")
  2239 + public ResponseEntity<SearchResponse> searchProducts(
  2240 + @RequestParam String storeId,
  2241 + @RequestBody SearchRequest request) {
  2242 +
  2243 + try {
  2244 + // 查询店铺配置,获取 tenant_id
  2245 + ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
  2246 + if (shop == null) {
  2247 + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
  2248 + }
  2249 +
  2250 + // 调用 Python 搜索服务
  2251 + SearchResponse response = searchService.search(shop.getTenantId(), request);
  2252 +
  2253 + // 记录搜索日志
  2254 + searchLogService.logSearch(shop.getId(), request.getQuery(), response.getTotal());
  2255 +
  2256 + return ResponseEntity.ok(response);
  2257 +
  2258 + } catch (Exception e) {
  2259 + log.error("Search failed: storeId={}, query={}", storeId, request.getQuery(), e);
  2260 + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
  2261 + }
  2262 + }
  2263 +}
  2264 +
  2265 +@Service
  2266 +public class SearchService {
  2267 +
  2268 + @Autowired
  2269 + private RestTemplate restTemplate;
  2270 +
  2271 + @Value("${search.service.url}")
  2272 + private String searchServiceUrl;
  2273 +
  2274 + /**
  2275 + * 调用 Python 搜索服务
  2276 + */
  2277 + public SearchResponse search(Long tenantId, SearchRequest request) {
  2278 + try {
  2279 + String url = searchServiceUrl + "/search/";
  2280 +
  2281 + // 添加租户隔离参数
  2282 + request.setCustomer("tenant_" + tenantId);
  2283 +
  2284 + ResponseEntity<SearchResponse> response = restTemplate.postForEntity(
  2285 + url,
  2286 + request,
  2287 + SearchResponse.class
  2288 + );
  2289 +
  2290 + if (response.getStatusCode().is2xxSuccessful()) {
  2291 + return response.getBody();
  2292 + }
  2293 +
  2294 + throw new BusinessException("Search service returned error: " + response.getStatusCode());
  2295 +
  2296 + } catch (Exception e) {
  2297 + log.error("Failed to call search service", e);
  2298 + throw new BusinessException("Search service error", e);
  2299 + }
  2300 + }
  2301 +}
  2302 +```
  2303 +
  2304 +### 9.2 店铺隔离
  2305 +
  2306 +每个店铺对应一个租户,使用不同的 ES 索引:
  2307 +
  2308 +```python
  2309 +# Python 搜索服务
  2310 +@app.post("/search/")
  2311 +async def search_products(request: SearchRequest):
  2312 + # 根据 customer 参数确定租户 ID
  2313 + tenant_id = extract_tenant_id(request.customer)
  2314 +
  2315 + # 使用租户专属索引
  2316 + index_name = f"shoplazza_products_{tenant_id}"
  2317 +
  2318 + # 构建 ES 查询
  2319 + es_query = build_es_query(request)
  2320 +
  2321 + # 执行搜索
  2322 + response = es_client.search(
  2323 + index=index_name,
  2324 + body=es_query
  2325 + )
  2326 +
  2327 + # 返回结果
  2328 + return format_search_response(response)
  2329 +```
  2330 +
  2331 +### 9.3 搜索行为统计
  2332 +
  2333 +#### 9.3.1 日志表设计
  2334 +
  2335 +```sql
  2336 +CREATE TABLE `shoplazza_search_log` (
  2337 + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  2338 + `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  2339 + `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
  2340 + `customer_id` VARCHAR(64) DEFAULT NULL COMMENT '客户ID',
  2341 + `session_id` VARCHAR(128) DEFAULT NULL COMMENT '会话ID',
  2342 + `query` VARCHAR(512) NOT NULL COMMENT '搜索关键词',
  2343 + `results_count` INT DEFAULT 0 COMMENT '结果数量',
  2344 + `search_type` VARCHAR(32) DEFAULT 'text' COMMENT '搜索类型:text, image, ai',
  2345 + `language` VARCHAR(16) DEFAULT NULL COMMENT '搜索语言',
  2346 + `has_results` BIT(1) DEFAULT b'1' COMMENT '是否有结果',
  2347 + `response_time_ms` INT DEFAULT NULL COMMENT '响应时间(毫秒)',
  2348 + `ip_address` VARCHAR(64) DEFAULT NULL COMMENT 'IP地址',
  2349 + `user_agent` VARCHAR(512) DEFAULT NULL COMMENT 'User Agent',
  2350 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  2351 + PRIMARY KEY (`id`),
  2352 + KEY `idx_tenant_id` (`tenant_id`),
  2353 + KEY `idx_store_id` (`store_id`),
  2354 + KEY `idx_query` (`query`),
  2355 + KEY `idx_created_at` (`created_at`)
  2356 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠搜索日志表';
  2357 +```
  2358 +
  2359 +#### 9.3.2 日志记录实现
  2360 +
  2361 +```java
  2362 +@Service
  2363 +public class SearchLogService {
  2364 +
  2365 + @Autowired
  2366 + private SearchLogMapper searchLogMapper;
  2367 +
  2368 + /**
  2369 + * 记录搜索日志
  2370 + */
  2371 + @Async
  2372 + public void logSearch(Long shopConfigId, SearchRequest request, SearchResponse response,
  2373 + long responseTime, HttpServletRequest httpRequest) {
  2374 + try {
  2375 + ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
  2376 +
  2377 + SearchLog log = new SearchLog();
  2378 + log.setTenantId(shop.getTenantId());
  2379 + log.setStoreId(shop.getStoreId());
  2380 + log.setCustomerId(request.getCustomerId());
  2381 + log.setSessionId(request.getSessionId());
  2382 + log.setQuery(request.getQuery());
  2383 + log.setResultsCount(response.getTotal());
  2384 + log.setSearchType(request.getSearchType());
  2385 + log.setLanguage(request.getLanguage());
  2386 + log.setHasResults(response.getTotal() > 0);
  2387 + log.setResponseTimeMs((int) responseTime);
  2388 + log.setIpAddress(getClientIp(httpRequest));
  2389 + log.setUserAgent(httpRequest.getHeader("User-Agent"));
  2390 +
  2391 + searchLogMapper.insert(log);
  2392 +
  2393 + } catch (Exception e) {
  2394 + log.error("Failed to log search", e);
  2395 + }
  2396 + }
  2397 +
  2398 + /**
  2399 + * 统计分析:热门搜索词
  2400 + */
  2401 + public List<SearchStats> getHotQueries(String storeId, int limit) {
  2402 + return searchLogMapper.selectHotQueries(storeId, limit);
  2403 + }
  2404 +
  2405 + /**
  2406 + * 统计分析:无结果搜索
  2407 + */
  2408 + public List<SearchStats> getNoResultQueries(String storeId, int limit) {
  2409 + return searchLogMapper.selectNoResultQueries(storeId, limit);
  2410 + }
  2411 +}
  2412 +```
  2413 +
  2414 +---
  2415 +
  2416 +## 10. 前端扩展开发
  2417 +
  2418 +### 10.1 主题扩展开发
  2419 +
  2420 +店匠使用 Liquid 模板语言开发主题扩展。
  2421 +
  2422 +#### 10.1.1 创建扩展项目
  2423 +
  2424 +```bash
  2425 +mkdir shoplazza-ai-search-app
  2426 +cd shoplazza-ai-search-app
  2427 +
  2428 +# 目录结构
  2429 +├── app-blocks/
  2430 +│ ├── search-box.liquid # 搜索框组件
  2431 +│ ├── search-results.liquid # 搜索结果组件
  2432 +│ └── settings.json # 组件配置
  2433 +├── assets/
  2434 +│ ├── search-box.js # JavaScript
  2435 +│ ├── search-box.css # 样式
  2436 +│ └── search-results.js
  2437 +├── locales/
  2438 +│ ├── en.json # 英文翻译
  2439 +│ ├── zh-CN.json # 中文翻译
  2440 +│ └── es.json # 西班牙语翻译
  2441 +└── config.json # APP 配置
  2442 +```
  2443 +
  2444 +#### 10.1.2 搜索框组件(search-box.liquid)
  2445 +
  2446 +```liquid
  2447 +<div class="ai-search-box" data-app-block="ai-search">
  2448 + <form class="search-form" onsubmit="return handleSearch(event)">
  2449 + <div class="search-input-wrapper">
  2450 + <input
  2451 + type="text"
  2452 + name="q"
  2453 + class="search-input"
  2454 + placeholder="{{ 'search.placeholder' | t }}"
  2455 + autocomplete="off"
  2456 + />
  2457 + <button type="submit" class="search-button">
  2458 + <svg class="search-icon" viewBox="0 0 24 24">
  2459 + <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
  2460 + </svg>
  2461 + </button>
  2462 + </div>
  2463 + <div class="search-suggestions" id="search-suggestions"></div>
  2464 + </form>
  2465 +</div>
  2466 +
  2467 +<script>
  2468 +window.AI_SEARCH_CONFIG = {
  2469 + storeId: "{{ shop.domain }}",
  2470 + apiEndpoint: "https://your-domain.com/api/search/products",
  2471 + locale: "{{ shop.locale }}"
  2472 +};
  2473 +</script>
  2474 +
  2475 +<link rel="stylesheet" href="{{ 'search-box.css' | asset_url }}">
  2476 +<script src="{{ 'search-box.js' | asset_url }}" defer></script>
  2477 +```
  2478 +
  2479 +#### 10.1.3 搜索框 JavaScript(search-box.js)
  2480 +
  2481 +```javascript
  2482 +// 搜索框功能
  2483 +(function() {
  2484 + const config = window.AI_SEARCH_CONFIG || {};
  2485 + let searchTimeout;
  2486 +
  2487 + function handleSearch(event) {
  2488 + event.preventDefault();
  2489 + const query = event.target.q.value.trim();
  2490 +
  2491 + if (!query) return false;
  2492 +
  2493 + // 跳转到搜索结果页
  2494 + window.location.href = `/pages/search-results?q=${encodeURIComponent(query)}`;
  2495 + return false;
  2496 + }
  2497 +
  2498 + // 搜索建议(自动补全)
  2499 + function setupAutocomplete() {
  2500 + const input = document.querySelector('.search-input');
  2501 + const suggestionsContainer = document.getElementById('search-suggestions');
  2502 +
  2503 + if (!input || !suggestionsContainer) return;
  2504 +
  2505 + input.addEventListener('input', function(e) {
  2506 + clearTimeout(searchTimeout);
  2507 + const query = e.target.value.trim();
  2508 +
  2509 + if (query.length < 2) {
  2510 + suggestionsContainer.innerHTML = '';
  2511 + suggestionsContainer.style.display = 'none';
  2512 + return;
  2513 + }
  2514 +
  2515 + searchTimeout = setTimeout(() => {
  2516 + fetchSuggestions(query);
  2517 + }, 300);
  2518 + });
  2519 +
  2520 + // 点击外部关闭建议
  2521 + document.addEventListener('click', function(e) {
  2522 + if (!e.target.closest('.ai-search-box')) {
  2523 + suggestionsContainer.style.display = 'none';
  2524 + }
  2525 + });
  2526 + }
  2527 +
  2528 + async function fetchSuggestions(query) {
  2529 + try {
  2530 + const response = await fetch(`${config.apiEndpoint}/suggestions?q=${encodeURIComponent(query)}&store_id=${config.storeId}`);
  2531 + const data = await response.json();
  2532 +
  2533 + if (data.suggestions && data.suggestions.length > 0) {
  2534 + renderSuggestions(data.suggestions);
  2535 + }
  2536 + } catch (error) {
  2537 + console.error('Failed to fetch suggestions:', error);
  2538 + }
  2539 + }
  2540 +
  2541 + function renderSuggestions(suggestions) {
  2542 + const container = document.getElementById('search-suggestions');
  2543 +
  2544 + const html = suggestions.map(item => `
  2545 + <div class="suggestion-item" onclick="selectSuggestion('${item.text}')">
  2546 + ${item.text}
  2547 + </div>
  2548 + `).join('');
  2549 +
  2550 + container.innerHTML = html;
  2551 + container.style.display = 'block';
  2552 + }
  2553 +
  2554 + window.selectSuggestion = function(text) {
  2555 + document.querySelector('.search-input').value = text;
  2556 + document.getElementById('search-suggestions').style.display = 'none';
  2557 + document.querySelector('.search-form').submit();
  2558 + };
  2559 +
  2560 + window.handleSearch = handleSearch;
  2561 +
  2562 + // 初始化
  2563 + if (document.readyState === 'loading') {
  2564 + document.addEventListener('DOMContentLoaded', setupAutocomplete);
  2565 + } else {
  2566 + setupAutocomplete();
  2567 + }
  2568 +})();
  2569 +```
  2570 +
  2571 +#### 10.1.4 搜索结果页(search-results.liquid)
  2572 +
  2573 +```liquid
  2574 +<div class="ai-search-results" data-app-block="ai-search-results">
  2575 + <div class="search-header">
  2576 + <h1>{{ 'search.title' | t }}</h1>
  2577 + <div class="search-query">
  2578 + {{ 'search.results_for' | t }}: <strong id="current-query"></strong>
  2579 + </div>
  2580 + </div>
  2581 +
  2582 + <div class="search-filters" id="search-filters">
  2583 + <!-- 动态生成的过滤器 -->
  2584 + </div>
  2585 +
  2586 + <div class="search-results-grid" id="search-results">
  2587 + <div class="loading">{{ 'search.loading' | t }}</div>
  2588 + </div>
  2589 +
  2590 + <div class="search-pagination" id="search-pagination"></div>
  2591 +</div>
  2592 +
  2593 +<script>
  2594 +window.AI_SEARCH_CONFIG = {
  2595 + storeId: "{{ shop.domain }}",
  2596 + apiEndpoint: "https://your-domain.com/api/search/products",
  2597 + locale: "{{ shop.locale }}",
  2598 + currency: "{{ shop.currency }}"
  2599 +};
  2600 +</script>
  2601 +
  2602 +<link rel="stylesheet" href="{{ 'search-results.css' | asset_url }}">
  2603 +<script src="{{ 'search-results.js' | asset_url }}" defer></script>
  2604 +```
  2605 +
  2606 +#### 10.1.5 搜索结果 JavaScript(search-results.js)
  2607 +
  2608 +```javascript
  2609 +(function() {
  2610 + const config = window.AI_SEARCH_CONFIG || {};
  2611 + let currentPage = 1;
  2612 + let currentQuery = '';
  2613 + let currentFilters = {};
  2614 +
  2615 + // 从 URL 获取搜索参数
  2616 + function getSearchParams() {
  2617 + const params = new URLSearchParams(window.location.search);
  2618 + return {
  2619 + query: params.get('q') || '',
  2620 + page: parseInt(params.get('page')) || 1
  2621 + };
  2622 + }
  2623 +
  2624 + // 执行搜索
  2625 + async function performSearch() {
  2626 + const params = getSearchParams();
  2627 + currentQuery = params.query;
  2628 + currentPage = params.page;
  2629 +
  2630 + if (!currentQuery) {
  2631 + showError('Please enter a search query');
  2632 + return;
  2633 + }
  2634 +
  2635 + document.getElementById('current-query').textContent = currentQuery;
  2636 + showLoading();
  2637 +
  2638 + try {
  2639 + const response = await fetch(config.apiEndpoint, {
  2640 + method: 'POST',
  2641 + headers: {
  2642 + 'Content-Type': 'application/json'
  2643 + },
  2644 + body: JSON.stringify({
  2645 + query: currentQuery,
  2646 + page: currentPage,
  2647 + size: 24,
  2648 + filters: currentFilters,
  2649 + facets: ['product_type', 'vendor', 'tags'],
  2650 + customer: `tenant_${config.storeId}`
  2651 + })
  2652 + });
  2653 +
  2654 + const data = await response.json();
  2655 +
  2656 + if (data.results) {
  2657 + renderResults(data.results);
  2658 + renderFacets(data.facets);
  2659 + renderPagination(data.total, currentPage, 24);
  2660 + } else {
  2661 + showError('No results found');
  2662 + }
  2663 + } catch (error) {
  2664 + console.error('Search failed:', error);
  2665 + showError('Search failed. Please try again.');
  2666 + }
  2667 + }
  2668 +
  2669 + // 渲染搜索结果
  2670 + function renderResults(results) {
  2671 + const container = document.getElementById('search-results');
  2672 +
  2673 + if (results.length === 0) {
  2674 + container.innerHTML = '<div class="no-results">No products found</div>';
  2675 + return;
  2676 + }
  2677 +
  2678 + const html = results.map(product => `
  2679 + <div class="product-card">
  2680 + <a href="/products/${product.handle}" class="product-link">
  2681 + <div class="product-image">
  2682 + <img src="${product.image_url || '/assets/placeholder.jpg'}" alt="${product.title}">
  2683 + </div>
  2684 + <div class="product-info">
  2685 + <h3 class="product-title">${product.title}</h3>
  2686 + <div class="product-vendor">${product.vendor || ''}</div>
  2687 + <div class="product-price">
  2688 + ${formatPrice(product.price, config.currency)}
  2689 + </div>
  2690 + </div>
  2691 + </a>
  2692 + </div>
  2693 + `).join('');
  2694 +
  2695 + container.innerHTML = html;
  2696 + }
  2697 +
  2698 + // 渲染分面过滤器
  2699 + function renderFacets(facets) {
  2700 + const container = document.getElementById('search-filters');
  2701 +
  2702 + if (!facets || Object.keys(facets).length === 0) {
  2703 + container.innerHTML = '';
  2704 + return;
  2705 + }
  2706 +
  2707 + let html = '<div class="filters-title">Filters</div>';
  2708 +
  2709 + for (const [field, values] of Object.entries(facets)) {
  2710 + if (values.length === 0) continue;
  2711 +
  2712 + html += `
  2713 + <div class="filter-group">
  2714 + <h4 class="filter-title">${formatFieldName(field)}</h4>
  2715 + <div class="filter-options">
  2716 + ${values.map(item => `
  2717 + <label class="filter-option">
  2718 + <input type="checkbox"
  2719 + value="${item.value}"
  2720 + onchange="toggleFilter('${field}', '${item.value}')">
  2721 + <span>${item.value} (${item.count})</span>
  2722 + </label>
  2723 + `).join('')}
  2724 + </div>
  2725 + </div>
  2726 + `;
  2727 + }
  2728 +
  2729 + container.innerHTML = html;
  2730 + }
  2731 +
  2732 + // 切换过滤器
  2733 + window.toggleFilter = function(field, value) {
  2734 + if (!currentFilters[field]) {
  2735 + currentFilters[field] = [];
  2736 + }
  2737 +
  2738 + const index = currentFilters[field].indexOf(value);
  2739 + if (index > -1) {
  2740 + currentFilters[field].splice(index, 1);
  2741 + if (currentFilters[field].length === 0) {
  2742 + delete currentFilters[field];
  2743 + }
  2744 + } else {
  2745 + currentFilters[field].push(value);
  2746 + }
  2747 +
  2748 + currentPage = 1;
  2749 + performSearch();
  2750 + };
  2751 +
  2752 + // 渲染分页
  2753 + function renderPagination(total, page, pageSize) {
  2754 + const container = document.getElementById('search-pagination');
  2755 + const totalPages = Math.ceil(total / pageSize);
  2756 +
  2757 + if (totalPages <= 1) {
  2758 + container.innerHTML = '';
  2759 + return;
  2760 + }
  2761 +
  2762 + let html = '<div class="pagination">';
  2763 +
  2764 + // 上一页
  2765 + if (page > 1) {
  2766 + html += `<a href="?q=${encodeURIComponent(currentQuery)}&page=${page - 1}" class="page-link">Previous</a>`;
  2767 + }
  2768 +
  2769 + // 页码
  2770 + for (let i = Math.max(1, page - 2); i <= Math.min(totalPages, page + 2); i++) {
  2771 + if (i === page) {
  2772 + html += `<span class="page-link active">${i}</span>`;
  2773 + } else {
  2774 + html += `<a href="?q=${encodeURIComponent(currentQuery)}&page=${i}" class="page-link">${i}</a>`;
  2775 + }
  2776 + }
  2777 +
  2778 + // 下一页
  2779 + if (page < totalPages) {
  2780 + html += `<a href="?q=${encodeURIComponent(currentQuery)}&page=${page + 1}" class="page-link">Next</a>`;
  2781 + }
  2782 +
  2783 + html += '</div>';
  2784 + container.innerHTML = html;
  2785 + }
  2786 +
  2787 + // 工具函数
  2788 + function formatPrice(price, currency) {
  2789 + return new Intl.NumberFormat('en-US', {
  2790 + style: 'currency',
  2791 + currency: currency || 'USD'
  2792 + }).format(price);
  2793 + }
  2794 +
  2795 + function formatFieldName(field) {
  2796 + return field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
  2797 + }
  2798 +
  2799 + function showLoading() {
  2800 + document.getElementById('search-results').innerHTML = '<div class="loading">Loading...</div>';
  2801 + }
  2802 +
  2803 + function showError(message) {
  2804 + document.getElementById('search-results').innerHTML = `<div class="error">${message}</div>`;
  2805 + }
  2806 +
  2807 + // 初始化
  2808 + if (document.readyState === 'loading') {
  2809 + document.addEventListener('DOMContentLoaded', performSearch);
  2810 + } else {
  2811 + performSearch();
  2812 + }
  2813 +})();
  2814 +```
  2815 +
  2816 +### 10.2 多语言支持
  2817 +
  2818 +#### 10.2.1 中文翻译(locales/zh-CN.json)
  2819 +
  2820 +```json
  2821 +{
  2822 + "search": {
  2823 + "placeholder": "搜索商品...",
  2824 + "title": "搜索结果",
  2825 + "results_for": "搜索",
  2826 + "loading": "加载中...",
  2827 + "no_results": "未找到相关商品",
  2828 + "filters": "筛选",
  2829 + "clear_filters": "清除筛选"
  2830 + }
  2831 +}
  2832 +```
  2833 +
  2834 +#### 10.2.2 英文翻译(locales/en.json)
  2835 +
  2836 +```json
  2837 +{
  2838 + "search": {
  2839 + "placeholder": "Search products...",
  2840 + "title": "Search Results",
  2841 + "results_for": "Search results for",
  2842 + "loading": "Loading...",
  2843 + "no_results": "No products found",
  2844 + "filters": "Filters",
  2845 + "clear_filters": "Clear filters"
  2846 + }
  2847 +}
  2848 +```
  2849 +
  2850 +### 10.3 主题装修集成
  2851 +
  2852 +商家可以在店铺后台的主题装修中添加搜索扩展:
  2853 +
  2854 +1. 进入店铺后台 → 主题 → 装修
  2855 +2. 点击"添加卡片"
  2856 +3. 选择"APPS"分类
  2857 +4. 找到"AI 搜索" APP
  2858 +5. 拖拽"搜索框"组件到导航栏或页面顶部
  2859 +6. 创建自定义页面"搜索结果",添加"搜索结果"组件
  2860 +7. 保存并发布主题
  2861 +
  2862 +---
  2863 +
  2864 +## 11. 部署和上线
  2865 +
  2866 +### 11.1 域名和 SSL 配置
  2867 +
  2868 +#### 11.1.1 域名申请
  2869 +
  2870 +申请一个公网域名,例如:
  2871 +```
  2872 +saas-ai-api.example.com
  2873 +```
  2874 +
  2875 +#### 11.1.2 SSL 证书配置
  2876 +
  2877 +使用 Let's Encrypt 或其他 CA 颁发的 SSL 证书:
  2878 +
  2879 +```bash
  2880 +# 使用 Certbot 申请证书
  2881 +sudo apt-get install certbot
  2882 +sudo certbot certonly --standalone -d saas-ai-api.example.com
  2883 +```
  2884 +
  2885 +#### 11.1.3 Nginx 配置
  2886 +
  2887 +```nginx
  2888 +server {
  2889 + listen 443 ssl http2;
  2890 + server_name saas-ai-api.example.com;
  2891 +
  2892 + ssl_certificate /etc/letsencrypt/live/saas-ai-api.example.com/fullchain.pem;
  2893 + ssl_certificate_key /etc/letsencrypt/live/saas-ai-api.example.com/privkey.pem;
  2894 +
  2895 + # OAuth 回调
  2896 + location /oauth/ {
  2897 + proxy_pass http://localhost:8080;
  2898 + proxy_set_header Host $host;
  2899 + proxy_set_header X-Real-IP $remote_addr;
  2900 + }
  2901 +
  2902 + # Webhook 接收
  2903 + location /webhook/ {
  2904 + proxy_pass http://localhost:8080;
  2905 + proxy_set_header Host $host;
  2906 + proxy_set_header X-Real-IP $remote_addr;
  2907 + }
  2908 +
  2909 + # 搜索 API
  2910 + location /api/search/ {
  2911 + proxy_pass http://localhost:8080;
  2912 + proxy_set_header Host $host;
  2913 + proxy_set_header X-Real-IP $remote_addr;
  2914 + }
  2915 +}
  2916 +```
  2917 +
  2918 +### 11.2 应用审核准备
  2919 +
  2920 +#### 11.2.1 应用商店信息
  2921 +
  2922 +在店匠 Partner 后台填写应用信息:
  2923 +
  2924 +**基本信息:**
  2925 +- APP 名称:AI 智能搜索
  2926 +- APP 图标:上传 512x512 PNG 图标
  2927 +- APP 分类:Search & Discovery
  2928 +- 短描述:为您的店铺提供多语言、语义搜索和 AI 推荐功能
  2929 +- 详细描述:(500-2000字,介绍功能特性、使用场景、优势)
  2930 +
  2931 +**应用截图:**
  2932 +- 至少 3 张截图(1280x800 或 1920x1080)
  2933 +- 搜索框界面截图
  2934 +- 搜索结果页截图
  2935 +- 后台管理界面截图
  2936 +
  2937 +**演示视频:**
  2938 +- 1-2分钟演示视频
  2939 +- 展示 APP 安装、配置、使用流程
  2940 +
  2941 +**定价信息:**
  2942 +- 免费试用期:14 天
  2943 +- 月费:$29.99/月
  2944 +- 年费:$299/年(节省 17%)
  2945 +
  2946 +#### 11.2.2 测试账号
  2947 +
  2948 +提供测试账号供店匠审核团队测试:
  2949 +
  2950 +```
  2951 +测试店铺:test-shop-12345.myshoplaza.com
  2952 +管理员账号:test@example.com
  2953 +管理员密码:TestPassword123!
  2954 +```
  2955 +
  2956 +#### 11.2.3 文档准备
  2957 +
  2958 +提供完整的文档:
  2959 +
  2960 +- **安装指南**:如何安装和配置 APP
  2961 +- **使用手册**:如何使用搜索功能
  2962 +- **API 文档**:开发者集成文档
  2963 +- **FAQ**:常见问题解答
  2964 +- **支持联系方式**:support@example.com
  2965 +
  2966 +### 11.3 审核和发布
  2967 +
  2968 +#### 11.3.1 提交审核
  2969 +
  2970 +1. 在 Partner 后台点击"提交审核"
  2971 +2. 填写审核说明
  2972 +3. 等待审核结果(通常 3-7 个工作日)
  2973 +
  2974 +#### 11.3.2 审核常见问题
  2975 +
  2976 +店匠应用审核的常见拒绝原因:
  2977 +
  2978 +1. **功能问题:**
  2979 + - 核心功能无法正常使用
  2980 + - 页面加载速度过慢
  2981 + - 移动端适配不良
  2982 +
  2983 +2. **权限问题:**
  2984 + - 申请了不必要的权限
  2985 + - 未说明权限用途
  2986 +
  2987 +3. **UI/UX 问题:**
  2988 + - 界面与店铺风格不一致
  2989 + - 缺少多语言支持
  2990 + - 操作流程不清晰
  2991 +
  2992 +4. **文档问题:**
  2993 + - 缺少必要的文档
  2994 + - 文档描述不清楚
  2995 + - 测试账号无法访问
  2996 +
  2997 +#### 11.3.3 应用发布
  2998 +
  2999 +审核通过后:
  3000 +
  3001 +1. 应用自动发布到店匠应用市场
  3002 +2. 商家可以搜索并安装你的 APP
  3003 +3. 开始正式运营和推广
  3004 +
  3005 +---
  3006 +
  3007 +## 12. 附录
  3008 +
  3009 +### 12.1 API 参考
  3010 +
  3011 +#### 12.1.1 店匠 API 端点速查表
  3012 +
  3013 +| API | 端点 | 方法 | 说明 |
  3014 +|-----|------|------|------|
  3015 +| **OAuth** |
  3016 +| 授权 URL | `/partner/oauth/authorize` | GET | 获取授权 |
  3017 +| 获取 Token | `/partner/oauth/token` | POST | 换取 Token |
  3018 +| **商品** |
  3019 +| 商品列表 | `/openapi/2022-01/products` | GET | 获取商品列表 |
  3020 +| 商品详情 | `/openapi/2022-01/products/{id}` | GET | 获取单个商品 |
  3021 +| 商品总数 | `/openapi/2022-01/products/count` | GET | 获取商品总数 |
  3022 +| **订单** |
  3023 +| 订单列表 | `/openapi/2022-01/orders` | GET | 获取订单列表 |
  3024 +| 订单详情 | `/openapi/2022-01/orders/{id}` | GET | 获取单个订单 |
  3025 +| **客户** |
  3026 +| 客户列表 | `/openapi/2022-01/customers` | GET | 获取客户列表 |
  3027 +| 客户详情 | `/openapi/2022-01/customers/{id}` | GET | 获取单个客户 |
  3028 +| **Webhook** |
  3029 +| 注册 Webhook | `/openapi/2022-01/webhooks` | POST | 注册事件通知 |
  3030 +| Webhook 列表 | `/openapi/2022-01/webhooks` | GET | 获取已注册列表 |
  3031 +| 删除 Webhook | `/openapi/2022-01/webhooks/{id}` | DELETE | 删除 Webhook |
  3032 +
  3033 +#### 12.1.2 搜索 API 请求示例
  3034 +
  3035 +**文本搜索:**
  3036 +
  3037 +```bash
  3038 +curl -X POST http://your-domain:6002/search/ \
  3039 + -H "Content-Type: application/json" \
  3040 + -d '{
  3041 + "query": "bluetooth headphone",
  3042 + "customer": "tenant_1",
  3043 + "size": 20,
  3044 + "from": 0,
  3045 + "filters": {
  3046 + "product_type": "Electronics"
  3047 + },
  3048 + "facets": ["vendor", "product_type", "tags"]
  3049 + }'
  3050 +```
  3051 +
  3052 +**图片搜索:**
  3053 +
  3054 +```bash
  3055 +curl -X POST http://your-domain:6002/search/image \
  3056 + -H "Content-Type: application/json" \
  3057 + -d '{
  3058 + "image_url": "https://example.com/image.jpg",
  3059 + "customer": "tenant_1",
  3060 + "size": 20
  3061 + }'
  3062 +```
  3063 +
  3064 +### 12.2 数据库表结构 DDL
  3065 +
  3066 +完整的数据库表创建脚本请参考第 4、6 章节中的 SQL 语句。
  3067 +
  3068 +**核心表列表:**
  3069 +- `system_tenant` - 租户表
  3070 +- `shoplazza_shop_config` - 店铺配置表
  3071 +- `shoplazza_product_spu` - 商品 SPU 表
  3072 +- `shoplazza_product_sku` - 商品 SKU 表
  3073 +- `shoplazza_product_image` - 商品图片表
  3074 +- `shoplazza_customer` - 客户表
  3075 +- `shoplazza_customer_address` - 客户地址表
  3076 +- `shoplazza_order` - 订单表
  3077 +- `shoplazza_order_item` - 订单明细表
  3078 +- `shoplazza_search_log` - 搜索日志表
  3079 +
  3080 +### 12.3 配置示例
  3081 +
  3082 +#### 12.3.1 application.yml 配置
  3083 +
  3084 +```yaml
  3085 +# OAuth 配置
  3086 +shoplazza:
  3087 + oauth:
  3088 + client-id: m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es
  3089 + client-secret: m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo
  3090 + redirect-uri: https://your-domain.com/oauth/callback
  3091 + scopes:
  3092 + - read_shop
  3093 + - read_product
  3094 + - read_order
  3095 + - read_customer
  3096 + - read_app_proxy
  3097 +
  3098 + # Webhook 配置
  3099 + webhook:
  3100 + base-url: https://your-domain.com/webhook/shoplazza
  3101 + topics:
  3102 + - products/create
  3103 + - products/update
  3104 + - products/delete
  3105 + - orders/create
  3106 + - customers/create
  3107 +
  3108 +# 搜索服务配置
  3109 +search:
  3110 + service:
  3111 + url: http://localhost:6002
  3112 + timeout: 30000
  3113 +
  3114 +# 向量服务配置
  3115 +embedding:
  3116 + service:
  3117 + url: http://localhost:6003
  3118 + timeout: 60000
  3119 +
  3120 +# Elasticsearch 配置
  3121 +elasticsearch:
  3122 + hosts: localhost:9200
  3123 + username: elastic
  3124 + password: changeme
  3125 +
  3126 +# 数据同步配置
  3127 +sync:
  3128 + enabled: true
  3129 + batch-size: 50
  3130 + schedule:
  3131 + products: "0 0 */1 * * ?" # 每小时
  3132 + orders: "0 0 3 * * ?" # 每天凌晨3点
  3133 + customers: "0 0 4 * * ?" # 每天凌晨4点
  3134 +```
  3135 +
  3136 +### 12.4 故障排查
  3137 +
  3138 +#### 12.4.1 OAuth 认证失败
  3139 +
  3140 +**问题:** 授权回调时报错 "Invalid redirect_uri"
  3141 +
  3142 +**解决:**
  3143 +1. 检查 Partner 后台配置的 Redirect URI 是否与代码中一致
  3144 +2. 确保 Redirect URI 使用 HTTPS 协议
  3145 +3. 确保 Redirect URI 可公网访问
  3146 +
  3147 +#### 12.4.2 Token 过期
  3148 +
  3149 +**问题:** API 调用返回 401 Unauthorized
  3150 +
  3151 +**解决:**
  3152 +1. 检查数据库中的 `token_expires_at` 字段
  3153 +2. 使用 Refresh Token 刷新 Access Token
  3154 +3. 更新数据库中的 Token 信息
  3155 +
  3156 +#### 12.4.3 API 调用速率限制
  3157 +
  3158 +**问题:** API 返回 429 Too Many Requests
  3159 +
  3160 +**解决:**
  3161 +1. 降低请求频率
  3162 +2. 实现指数退避重试
  3163 +3. 解析响应头中的 `X-RateLimit-Reset` 字段,等待到指定时间后再重试
  3164 +
  3165 +#### 12.4.4 Webhook 接收失败
  3166 +
  3167 +**问题:** Webhook 事件未收到或签名验证失败
  3168 +
  3169 +**解决:**
  3170 +1. 检查 Webhook 地址是否可公网访问
  3171 +2. 检查签名验证逻辑是否正确使用 Client Secret
  3172 +3. 查看店匠后台的 Webhook 日志,确认发送状态
  3173 +4. 确保 Webhook 处理在 3 秒内返回 200 响应
  3174 +
  3175 +#### 12.4.5 商品搜索无结果
  3176 +
  3177 +**问题:** 搜索返回空结果
  3178 +
  3179 +**解决:**
  3180 +1. 检查 ES 索引是否存在:`GET /shoplazza_products_1/_count`
  3181 +2. 检查商品是否已索引:`GET /shoplazza_products_1/_search`
  3182 +3. 检查租户隔离参数是否正确
  3183 +4. 查看搜索服务日志,确认查询语句
  3184 +
  3185 +#### 12.4.6 向量生成失败
  3186 +
  3187 +**问题:** 图片或文本向量生成失败
  3188 +
  3189 +**解决:**
  3190 +1. 检查向量服务是否正常运行
  3191 +2. 检查向量服务的 GPU/CPU 资源是否充足
  3192 +3. 检查图片 URL 是否可访问
  3193 +4. 查看向量服务日志
  3194 +
  3195 +---
  3196 +
  3197 +## 13. 参考资料
  3198 +
  3199 +### 13.1 官方文档
  3200 +
  3201 +- [店匠开发者文档](https://www.shoplazza.dev/reference/overview-29)
  3202 +- [店匠 OAuth 文档](https://www.shoplazza.dev/v2024.07/reference/authentication)
  3203 +- [店匠 API 参考](https://www.shoplazza.dev/v2024.07/reference/overview)
  3204 +- [店匠 Webhook 文档](https://www.shoplazza.dev/v2024.07/reference/webhooks)
  3205 +
  3206 +### 13.2 技术栈文档
  3207 +
  3208 +- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749)
  3209 +- [Elasticsearch 官方文档](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html)
  3210 +- [Liquid 模板语言](https://shopify.github.io/liquid/)
  3211 +- [FastAPI 文档](https://fastapi.tiangolo.com/)
  3212 +
  3213 +### 13.3 联系支持
  3214 +
  3215 +如有问题,请联系:
  3216 +
  3217 +- **技术支持邮箱**: support@example.com
  3218 +- **开发者社区**: https://community.example.com
  3219 +- **GitHub Issues**: https://github.com/your-org/search-saas/issues
  3220 +
  3221 +---
  3222 +
  3223 +**文档版本**: v1.0
  3224 +**最后更新**: 2025-11-12
  3225 +**维护团队**: 搜索 SaaS 开发团队
  3226 +
... ...
config/config_loader.py
... ... @@ -52,6 +52,13 @@ class QueryConfig:
52 52 translation_api_key: Optional[str] = None
53 53 translation_service: str = "deepl" # deepl, google, etc.
54 54  
  55 + # ES source fields configuration - fields to return in search results
  56 + source_fields: List[str] = field(default_factory=lambda: [
  57 + "id", "spuId", "skuNo", "spuNo", "title", "enSpuName", "brandId",
  58 + "brandName", "enBrandName", "categoryId", "categoryName", "enCategoryName",
  59 + "price", "originalPrice", "currency", "image", "status", "createdAt", "updatedAt"
  60 + ])
  61 +
55 62  
56 63 @dataclass
57 64 class SPUConfig:
... ...
search/es_query_builder.py
... ... @@ -17,7 +17,8 @@ class ESQueryBuilder:
17 17 index_name: str,
18 18 match_fields: List[str],
19 19 text_embedding_field: Optional[str] = None,
20   - image_embedding_field: Optional[str] = None
  20 + image_embedding_field: Optional[str] = None,
  21 + source_fields: Optional[List[str]] = None
21 22 ):
22 23 """
23 24 Initialize query builder.
... ... @@ -27,11 +28,13 @@ class ESQueryBuilder:
27 28 match_fields: Fields to search for text matching
28 29 text_embedding_field: Field name for text embeddings
29 30 image_embedding_field: Field name for image embeddings
  31 + source_fields: Fields to return in search results (_source includes)
30 32 """
31 33 self.index_name = index_name
32 34 self.match_fields = match_fields
33 35 self.text_embedding_field = text_embedding_field
34 36 self.image_embedding_field = image_embedding_field
  37 + self.source_fields = source_fields
35 38  
36 39 def build_query(
37 40 self,
... ... @@ -71,6 +74,12 @@ class ESQueryBuilder:
71 74 "from": from_
72 75 }
73 76  
  77 + # Add _source filtering if source_fields are configured
  78 + if self.source_fields:
  79 + es_query["_source"] = {
  80 + "includes": self.source_fields
  81 + }
  82 +
74 83 # Build main query
75 84 if query_node and query_node.operator != 'TERM':
76 85 # Complex boolean query
... ...
search/multilang_query_builder.py
... ... @@ -29,7 +29,8 @@ class MultiLanguageQueryBuilder(ESQueryBuilder):
29 29 config: CustomerConfig,
30 30 index_name: str,
31 31 text_embedding_field: Optional[str] = None,
32   - image_embedding_field: Optional[str] = None
  32 + image_embedding_field: Optional[str] = None,
  33 + source_fields: Optional[List[str]] = None
33 34 ):
34 35 """
35 36 Initialize multi-language query builder.
... ... @@ -39,6 +40,7 @@ class MultiLanguageQueryBuilder(ESQueryBuilder):
39 40 index_name: ES index name
40 41 text_embedding_field: Field name for text embeddings
41 42 image_embedding_field: Field name for image embeddings
  43 + source_fields: Fields to return in search results (_source includes)
42 44 """
43 45 self.config = config
44 46 self.function_score_config = config.function_score
... ... @@ -50,7 +52,8 @@ class MultiLanguageQueryBuilder(ESQueryBuilder):
50 52 index_name=index_name,
51 53 match_fields=default_fields,
52 54 text_embedding_field=text_embedding_field,
53   - image_embedding_field=image_embedding_field
  55 + image_embedding_field=image_embedding_field,
  56 + source_fields=source_fields
54 57 )
55 58  
56 59 # Build domain configurations
... ... @@ -205,6 +208,12 @@ class MultiLanguageQueryBuilder(ESQueryBuilder):
205 208 "query": function_score_query
206 209 }
207 210  
  211 + # Add _source filtering if source_fields are configured
  212 + if self.source_fields:
  213 + es_query["_source"] = {
  214 + "includes": self.source_fields
  215 + }
  216 +
208 217 if min_score is not None:
209 218 es_query["min_score"] = min_score
210 219  
... ...
search/searcher.py
... ... @@ -99,7 +99,8 @@ class Searcher:
99 99 config=config,
100 100 index_name=config.es_index_name,
101 101 text_embedding_field=self.text_embedding_field,
102   - image_embedding_field=self.image_embedding_field
  102 + image_embedding_field=self.image_embedding_field,
  103 + source_fields=config.query_config.source_fields
103 104 )
104 105  
105 106 def search(
... ... @@ -513,6 +514,12 @@ class Searcher:
513 514 }
514 515 }
515 516  
  517 + # Add _source filtering if source_fields are configured
  518 + if self.config.query_config.source_fields:
  519 + es_query["_source"] = {
  520 + "includes": self.config.query_config.source_fields
  521 + }
  522 +
516 523 if filters or range_filters:
517 524 filter_clauses = self.query_builder._build_filters(filters, range_filters)
518 525 if filter_clauses:
... ...
test_search_with_source_fields.py 0 → 100644
... ... @@ -0,0 +1,147 @@
  1 +#!/usr/bin/env python3
  2 +"""
  3 +测试实际搜索功能中的source_fields应用
  4 +"""
  5 +
  6 +import sys
  7 +import os
  8 +import json
  9 +sys.path.append(os.path.dirname(os.path.abspath(__file__)))
  10 +
  11 +from config import ConfigLoader
  12 +
  13 +def test_search_query_structure():
  14 + """测试搜索查询是否正确应用了source_fields"""
  15 + print("测试搜索查询中的source_fields应用...")
  16 +
  17 + try:
  18 + from search.searcher import Searcher
  19 + from utils.es_client import ESClient
  20 +
  21 + # 加载配置
  22 + config_loader = ConfigLoader("config/schema")
  23 + config = config_loader.load_customer_config("customer1")
  24 +
  25 + print(f"✓ 配置加载成功: {config.customer_id}")
  26 + print(f" source_fields配置数量: {len(config.query_config.source_fields)}")
  27 +
  28 + # 创建ES客户端(使用模拟客户端避免实际连接)
  29 + class MockESClient:
  30 + def search(self, index_name, body, size=10, from_=0):
  31 + print(f"模拟ES搜索 - 索引: {index_name}")
  32 + print(f"查询body结构:")
  33 + print(json.dumps(body, indent=2, ensure_ascii=False))
  34 +
  35 + # 检查_source配置
  36 + if "_source" in body:
  37 + print("✓ 查询包含_source配置")
  38 + source_config = body["_source"]
  39 + if "includes" in source_config:
  40 + print(f"✓ source includes字段: {source_config['includes']}")
  41 + return {
  42 + 'took': 5,
  43 + 'hits': {
  44 + 'total': {'value': 0},
  45 + 'max_score': 0.0,
  46 + 'hits': []
  47 + }
  48 + }
  49 + else:
  50 + print("✗ _source配置中缺少includes")
  51 + return None
  52 + else:
  53 + print("✗ 查询中缺少_source配置")
  54 + return None
  55 +
  56 + def client(self):
  57 + return self
  58 +
  59 + # 创建Searcher实例
  60 + es_client = MockESClient()
  61 + searcher = Searcher(config, es_client)
  62 +
  63 + print("\n测试文本搜索...")
  64 + result = searcher.search("test query", size=5)
  65 +
  66 + if result:
  67 + print("✓ 文本搜索测试成功")
  68 + else:
  69 + print("✗ 文本搜索测试失败")
  70 +
  71 + print("\n测试图像搜索...")
  72 + try:
  73 + result = searcher.search_by_image("http://example.com/image.jpg", size=3)
  74 + if result:
  75 + print("✓ 图像搜索测试成功")
  76 + else:
  77 + print("✗ 图像搜索测试失败")
  78 + except Exception as e:
  79 + print(f"✗ 图像搜索测试失败: {e}")
  80 +
  81 + return True
  82 +
  83 + except Exception as e:
  84 + print(f"✗ 搜索测试失败: {e}")
  85 + import traceback
  86 + traceback.print_exc()
  87 + return False
  88 +
  89 +def test_es_query_builder_integration():
  90 + """测试ES查询构建器的集成"""
  91 + print("\n测试ES查询构建器集成...")
  92 +
  93 + try:
  94 + from search.es_query_builder import ESQueryBuilder
  95 +
  96 + # 创建构建器,传入空的source_fields列表
  97 + builder = ESQueryBuilder(
  98 + index_name="test_index",
  99 + match_fields=["title", "content"],
  100 + source_fields=None # 测试空配置的情况
  101 + )
  102 +
  103 + query = builder.build_query("test query")
  104 +
  105 + if "_source" not in query:
  106 + print("✓ 空source_fields配置下,查询不包含_source过滤")
  107 + else:
  108 + print("⚠ 空source_fields配置下,查询仍然包含_source过滤")
  109 +
  110 + # 测试非空配置
  111 + builder2 = ESQueryBuilder(
  112 + index_name="test_index",
  113 + match_fields=["title", "content"],
  114 + source_fields=["id", "title"]
  115 + )
  116 +
  117 + query2 = builder2.build_query("test query")
  118 +
  119 + if "_source" in query2 and "includes" in query2["_source"]:
  120 + print("✓ 非空source_fields配置下,查询正确包含_source过滤")
  121 + else:
  122 + print("✗ 非空source_fields配置下,查询缺少_source过滤")
  123 +
  124 + return True
  125 +
  126 + except Exception as e:
  127 + print(f"✗ 查询构建器集成测试失败: {e}")
  128 + return False
  129 +
  130 +if __name__ == "__main__":
  131 + print("=" * 60)
  132 + print("搜索功能source_fields应用测试")
  133 + print("=" * 60)
  134 +
  135 + success = True
  136 +
  137 + # 运行所有测试
  138 + success &= test_es_query_builder_integration()
  139 + success &= test_search_query_structure()
  140 +
  141 + print("\n" + "=" * 60)
  142 + if success:
  143 + print("✓ 所有测试通过!source_fields在搜索功能中正确应用。")
  144 + print("✓ ES现在只返回配置中指定的字段,减少了网络传输和响应大小。")
  145 + else:
  146 + print("✗ 部分测试失败,请检查实现。")
  147 + print("=" * 60)
0 148 \ No newline at end of file
... ...
test_source_fields.py 0 → 100644
... ... @@ -0,0 +1,132 @@
  1 +#!/usr/bin/env python3
  2 +"""
  3 +测试ES source_fields配置的脚本
  4 +"""
  5 +
  6 +import sys
  7 +import os
  8 +sys.path.append(os.path.dirname(os.path.abspath(__file__)))
  9 +
  10 +from config import ConfigLoader, CustomerConfig
  11 +
  12 +def test_source_fields_config():
  13 + """测试source_fields配置是否正确加载"""
  14 + print("测试ES source_fields配置...")
  15 +
  16 + # 加载配置
  17 + config_loader = ConfigLoader("config/schema")
  18 +
  19 + try:
  20 + # 加载customer1配置
  21 + config = config_loader.load_customer_config("customer1")
  22 + print(f"✓ 成功加载配置: {config.customer_id}")
  23 +
  24 + # 检查source_fields配置
  25 + source_fields = config.query_config.source_fields
  26 + print(f"✓ source_fields配置 ({len(source_fields)}个字段):")
  27 + for i, field in enumerate(source_fields, 1):
  28 + print(f" {i:2d}. {field}")
  29 +
  30 + # 检查默认字段列表是否包含预期字段
  31 + expected_fields = ["id", "title", "brandName", "price", "image"]
  32 + for field in expected_fields:
  33 + if field in source_fields:
  34 + print(f"✓ 包含预期字段: {field}")
  35 + else:
  36 + print(f"⚠ 缺少预期字段: {field}")
  37 +
  38 + return True
  39 +
  40 + except Exception as e:
  41 + print(f"✗ 配置加载失败: {e}")
  42 + return False
  43 +
  44 +def test_es_query_builder():
  45 + """测试ES查询构建器是否正确应用source_fields"""
  46 + print("\n测试ES查询构建器...")
  47 +
  48 + try:
  49 + from search.es_query_builder import ESQueryBuilder
  50 +
  51 + # 测试基础查询构建器
  52 + builder = ESQueryBuilder(
  53 + index_name="test_index",
  54 + match_fields=["title", "content"],
  55 + source_fields=["id", "title", "price"]
  56 + )
  57 +
  58 + # 构建查询
  59 + query = builder.build_query("test query")
  60 +
  61 + print("✓ ES查询构建成功")
  62 + print(f"查询结构:")
  63 + print(f" size: {query.get('size')}")
  64 + print(f" _source: {query.get('_source')}")
  65 +
  66 + # 检查_source配置
  67 + if "_source" in query:
  68 + source_config = query["_source"]
  69 + if "includes" in source_config:
  70 + print(f"✓ _source includes配置正确: {source_config['includes']}")
  71 + else:
  72 + print("✗ _source配置中缺少includes字段")
  73 + else:
  74 + print("✗ 查询中缺少_source配置")
  75 +
  76 + return True
  77 +
  78 + except Exception as e:
  79 + print(f"✗ ES查询构建器测试失败: {e}")
  80 + import traceback
  81 + traceback.print_exc()
  82 + return False
  83 +
  84 +def test_multilang_query_builder():
  85 + """测试多语言查询构建器"""
  86 + print("\n测试多语言查询构建器...")
  87 +
  88 + try:
  89 + from search.multilang_query_builder import MultiLanguageQueryBuilder
  90 +
  91 + # 加载配置
  92 + config_loader = ConfigLoader("config/schema")
  93 + config = config_loader.load_customer_config("customer1")
  94 +
  95 + # 创建多语言查询构建器
  96 + builder = MultiLanguageQueryBuilder(
  97 + config=config,
  98 + index_name=config.es_index_name,
  99 + text_embedding_field="text_embedding",
  100 + image_embedding_field="image_embedding",
  101 + source_fields=config.query_config.source_fields
  102 + )
  103 +
  104 + print("✓ 多语言查询构建器创建成功")
  105 + print(f" source_fields配置: {builder.source_fields}")
  106 +
  107 + return True
  108 +
  109 + except Exception as e:
  110 + print(f"✗ 多语言查询构建器测试失败: {e}")
  111 + import traceback
  112 + traceback.print_exc()
  113 + return False
  114 +
  115 +if __name__ == "__main__":
  116 + print("=" * 60)
  117 + print("ES Source Fields 配置测试")
  118 + print("=" * 60)
  119 +
  120 + success = True
  121 +
  122 + # 运行所有测试
  123 + success &= test_source_fields_config()
  124 + success &= test_es_query_builder()
  125 + success &= test_multilang_query_builder()
  126 +
  127 + print("\n" + "=" * 60)
  128 + if success:
  129 + print("✓ 所有测试通过!source_fields配置已正确实现。")
  130 + else:
  131 + print("✗ 部分测试失败,请检查配置和代码。")
  132 + print("=" * 60)
0 133 \ No newline at end of file
... ...
当前开发进度.md renamed to 设计文档.md