diff --git a/.cursor/plans/spu-index-b5a93a00.plan.md b/.cursor/plans/spu-index-b5a93a00.plan.md
deleted file mode 100644
index 693ae80..0000000
--- a/.cursor/plans/spu-index-b5a93a00.plan.md
+++ /dev/null
@@ -1,302 +0,0 @@
-
-# SPU-Level Indexing with Shoplazza API Format & BASE Configuration
-
-## Phase 1: Schema Analysis & Design
-
-### 1.1 Analyze SPU/SKU Fields for Indexing
-
-**SPU Fields** (from `shoplazza_product_spu`):
-
-- **Index + Store**: `id`, `shop_id`, `handle`, `title`, `brief`, `vendor`, `product_type`, `tags`, `category`, `image_src`, `published`, `published_at`
-- **Index only**: `seo_title`, `seo_description`, `seo_keywords`
-- **Store only**: `description` (HTML, no tokenization)
-
-**SKU Fields** (from `shoplazza_product_sku`, as nested array):
-
-- **Index + Store**: `id`, `title`, `sku`, `barcode`, `price`, `compare_at_price`, `option1`, `option2`, `option3`, `inventory_quantity`, `image_src`
-
-**Price Strategy (CONFIRMED - Option B)**:
-
-- Flatten `min_price`, `max_price`, `compare_at_price` at SPU level for fast filtering/sorting
-- Keep full variant prices in nested array for display
-
-### 1.2 Design BASE Schema
-
-**Index**: `search_products` (shared by all tenants)
-
-**Key fields**:
-
-- `tenant_id` (KEYWORD, **REQUIRED**) - always filtered, never optional
-- SPU-level flattened fields
-- `variants` (NESTED array) - SKU data
-- Flattened: `min_price`, `max_price`, `compare_at_price`
-- Multi-language: `title_zh`, `title_en`, `title_ru`, etc.
-- Embeddings: `title_embedding`, `image_embedding`
-
-## Phase 2: BASE Configuration (Universal Standard)
-
-### 2.1 Create BASE Config
-
-**File**: [`config/schema/base/config.yaml`](config/schema/base/config.yaml)
-
-**This is the universal configuration for ALL merchants using Shoplazza tables.**
-
-Key points:
-
-- Index name: `search_products` (shared)
-- Required field: `tenant_id` (always filtered in queries)
-- SPU-level fields with multi-language support
-- Nested variants structure
-- Flattened price fields: `min_price`, `max_price`, `compare_at_price`
-- Function_score configuration
-
-### 2.2 Update Field Types & Mapping
-
-**Files**: [`config/field_types.py`](config/field_types.py), [`indexer/mapping_generator.py`](indexer/mapping_generator.py)
-
-- Add `NESTED` field type
-- Handle nested mapping generation for variants
-- Auto-generate flattened price fields
-
-## Phase 3: Data Ingestion for BASE
-
-### 3.1 SPU-Level Data Transformer
-
-**File**: [`indexer/spu_data_transformer.py`](indexer/spu_data_transformer.py)
-
-Features:
-
-- Load SPU from `shoplazza_product_spu`
-- Join SKU from `shoplazza_product_sku` (grouped by spu_id)
-- Create nested variants array
-- Calculate `min_price`, `max_price`, `compare_at_price`
-- Generate title & image embeddings
-- Inject `tenant_id` from config
-
-### 3.2 Test Data Generator
-
-**File**: [`scripts/generate_shoplazza_test_data.py`](scripts/generate_shoplazza_test_data.py)
-
-Generate 100 SPU records with:
-
-- 10 categories, multiple vendors
-- Multi-language (zh/en/ru)
-- Price range: $5-$500
-- 1-5 variants per SPU (color, size options)
-- Insert into MySQL Shoplazza tables
-
-### 3.3 BASE Ingestion Script
-
-**File**: [`scripts/ingest_base.py`](scripts/ingest_base.py)
-
-- Load from MySQL `shoplazza_product_spu` + `shoplazza_product_sku`
-- Use `SPUDataTransformer`
-- Index into `search_products` with configured `tenant_id`
-
-## Phase 4: Query Updates
-
-### 4.1 Query Builder Enhancements
-
-**File**: [`search/multilang_query_builder.py`](search/multilang_query_builder.py)
-
-- **Auto-inject `tenant_id` filter** (from config, always applied)
-- Support nested queries for variants
-- Use flattened price fields for filters: `min_price`, `max_price`
-
-### 4.2 Searcher Updates
-
-**File**: [`search/searcher.py`](search/searcher.py)
-
-- Enforce `tenant_id` filtering
-- Handle nested inner_hits for variants
-
-## Phase 5: API Response Transformation
-
-### 5.1 Response Transformer
-
-**File**: [`api/response_transformer.py`](api/response_transformer.py)
-
-Transform ES response to Shoplazza format:
-
-- Extract variants from nested array
-- Map fields: `product_id`, `title`, `handle`, `vendor`, `product_type`, `tags`, `price`, `variants`, etc.
-- Calculate `in_stock` from variants
-
-### 5.2 Update API Models
-
-**File**: [`api/models.py`](api/models.py)
-
-New models:
-
-- `VariantOption`, `ProductVariant`, `ProductResult`
-- Updated `SearchResponse` with `results: List[ProductResult]`
-- Add placeholders: `suggestions: List[str] = []`, `related_searches: List[str] = []`
-
-### 5.3 Update Search Routes
-
-**File**: [`api/routes/search.py`](api/routes/search.py)
-
-- Use `ResponseTransformer` to convert ES hits
-- Return new Shoplazza-compatible format
-- **Ensure `tenant_id` is required in request**
-
-## Phase 6: Legacy Migration
-
-### 6.1 Rename Customer1 to Legacy
-
-- Rename [`config/schema/customer1/`](config/schema/customer1/) to [`config/schema/customer1_legacy/`](config/schema/customer1_legacy/)
-- Update config to use old index `search_customer1` (preserve for backward compatibility)
-- Mark as deprecated in comments
-
-### 6.2 Update Scripts for BASE
-
-- [`run.sh`](run.sh): Use BASE config, `search_products` index
-- [`restart.sh`](restart.sh): Use BASE config
-- [`test_all.sh`](test_all.sh): Test BASE config
-- Legacy scripts: Rename with `_legacy` suffix (e.g., `run_legacy.sh`)
-
-### 6.3 Update Frontend
-
-**Files**: [`frontend/`](frontend/) HTML/JS files
-
-- Change index name references from `search_customer1` to `search_products`
-- Use BASE config endpoints
-- Archive old frontend as `frontend_legacy/` if needed
-
-## Phase 7: API Documentation Updates
-
-### 7.1 Update API Docs
-
-**File**: [`API_DOCUMENTATION.md`](API_DOCUMENTATION.md)
-
-**Critical additions**:
-
-- **Document `tenant_id` as REQUIRED parameter** in all search requests
-- Explain that `tenant_id` filter is always applied
-- Update all response examples to new Shoplazza format
-- Document `suggestions` and `related_searches` (not yet implemented)
-- Add nested variant query examples
-- Multi-tenant isolation guarantees
-
-### 7.2 Update Request Models
-
-**File**: [`api/models.py`](api/models.py)
-
-Add `tenant_id` to `SearchRequest`:
-
-```python
-class SearchRequest(BaseModel):
- tenant_id: str = Field(..., description="租户ID (必需)")
- query: str = Field(...)
- # ... other fields
-```
-
-## Phase 8: Design Documentation
-
-### 8.1 Update Design Doc
-
-**File**: [`设计文档.md`](设计文档.md)
-
-Updates:
-
-- **索引粒度**: 改为 SPU 维度(非SKU)
-- **统一索引**: 所有租户共用 `search_products`,通过 `tenant_id` 隔离
-- **BASE配置**: 说明BASE配置为通用标准,所有新商户使用
-- **API响应格式**: 采用 Shoplazza 标准格式
-- **Price扁平化**: 说明高频字段的性能优化策略
-- **Nested变体**: 详细说明 variants 数组结构
-- **Legacy配置**: customer1等为遗留配置,仅用于兼容
-
-### 8.2 Create BASE Guide
-
-**File**: [`docs/BASE_CONFIG_GUIDE.md`](docs/BASE_CONFIG_GUIDE.md)
-
-Contents:
-
-- BASE configuration overview
-- How to generate test data
-- How to run ingestion for new tenant
-- Search examples
-- Response format examples
-- Multi-tenant isolation
-
-### 8.3 Create Migration Guide
-
-**File**: [`docs/MIGRATION_TO_BASE.md`](docs/MIGRATION_TO_BASE.md)
-
-- Breaking changes from SKU-level to SPU-level
-- Response format changes
-- How existing deployments should migrate
-- Legacy config deprecation timeline
-
-## Phase 9: Testing
-
-### 9.1 Create Test Script
-
-**File**: [`scripts/test_base.sh`](scripts/test_base.sh)
-
-Steps:
-
-1. Generate 100 test SPU records
-2. Run BASE ingestion with tenant_id="test_tenant"
-3. Run searches, verify response format
-4. Test faceted search
-5. Verify multi-tenant isolation
-6. Verify `tenant_id` filtering
-
-### 9.2 Integration Tests
-
-**File**: [`tests/test_base_integration.py`](tests/test_base_integration.py)
-
-- Test SPU-level indexing
-- Test nested variant queries
-- Test price filtering with flattened fields
-- Test tenant_id isolation
-- Test response transformation
-
-## Key Architectural Decisions
-
-### BASE Configuration Philosophy
-
-**BASE = Universal Standard**: All new merchants use BASE config with Shoplazza tables. No per-customer schema customization. Customization happens through:
-
-- Configuration parameters (analyzers, function_score, etc.)
-- Extension tables (if needed for additional fields)
-- NOT through separate schemas
-
-### Tenant Isolation
-
-**tenant_id is SACRED**:
-
-- Always present in queries (enforced at query builder)
-- Never optional
-- Guarantees data isolation between tenants
-- Documented prominently in API docs
-
-### Price Flattening Rationale
-
-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.
-
-### Legacy vs BASE
-
-- **BASE**: New standard, all future merchants
-- **Legacy (customer1_legacy)**: Deprecated, exists only for backward compatibility
-- All scripts/frontend default to BASE
-- Legacy access requires explicit suffix (`_legacy`)
-
-### To-dos
-
-- [ ] Analyze SPU/SKU fields and design unified schema with tenant_id, nested variants, and flattened price fields
-- [ ] Create unified schema config for multi-tenant SPU-level indexing
-- [ ] Add NESTED field type support to field_types.py and mapping generator
-- [ ] Create SPU-level data transformer that joins SPU+SKU tables and creates nested variant array
-- [ ] Create script to generate 100 realistic SPU+SKU test records in Shoplazza tables
-- [ ] Create customer2 configuration using unified schema and Shoplazza tables only
-- [ ] Create customer2 ingestion script that loads from MySQL Shoplazza tables
-- [ ] Update query builder to support tenant_id filtering and nested variant queries
-- [ ] Create response transformer to convert ES format to Shoplazza-compatible format
-- [ ] Update API models with new Shoplazza response format (ProductResult, variants, suggestions, etc.)
-- [ ] Update search routes to use response transformer and return new format
-- [ ] Migrate customer1 configuration to unified schema and SPU-level indexing
-- [ ] Create customer2 guide, update design docs, API docs, and create migration guide
-- [ ] Create comprehensive test script for customer2 with data generation, ingestion, and search validation
\ No newline at end of file
diff --git a/.cursor/plans/将数据pipeline相关配置从索引配置中剥离.md b/.cursor/plans/将数据pipeline相关配置从索引配置中剥离.md
new file mode 100644
index 0000000..64ed6b5
--- /dev/null
+++ b/.cursor/plans/将数据pipeline相关配置从索引配置中剥离.md
@@ -0,0 +1,469 @@
+
+# Configuration and Pipeline Separation Refactoring
+
+## Overview
+
+Implement clean separation between **Search Configuration** (customer-facing, ES/search focused) and **Data Pipeline** (internal ETL, script-controlled). Configuration files will only contain search engine settings, while data source and transformation logic will be controlled entirely by script parameters.
+
+## Phase 1: Configuration File Cleanup
+
+### 1.1 Clean BASE Configuration
+
+**File**: [`config/schema/base/config.yaml`](config/schema/base/config.yaml)
+
+**Remove** (data pipeline concerns):
+
+- `mysql_config` section
+- `main_table` field
+- `sku_table` field
+- `extension_table` field
+- `source_table` in field definitions
+- `source_column` in field definitions
+
+**Keep** (search configuration):
+
+- `customer_name`
+- `es_index_name`
+- `es_settings`
+- `fields` (simplified, no source mapping)
+- `indexes` (search domains)
+- `query_config`
+- `function_score`
+- `rerank`
+- `spu_config`
+- `tenant_config` (as template)
+- `default_facets`
+
+**Simplify field definitions**:
+
+```yaml
+fields:
+ - name: "title"
+ type: "TEXT"
+ analyzer: "chinese_ecommerce"
+ boost: 3.0
+ index: true
+ store: true
+ # NO source_table, NO source_column
+```
+
+### 1.2 Update Legacy Configuration
+
+**File**: [`config/schema/customer1_legacy/config.yaml`](config/schema/customer1_legacy/config.yaml)
+
+Apply same cleanup as BASE config, marking it as legacy in comments.
+
+## Phase 2: Transformer Architecture Refactoring
+
+### 2.1 Create Base Transformer Class
+
+**File**: [`indexer/base_transformer.py`](indexer/base_transformer.py) (NEW)
+
+Create abstract base class with shared logic:
+
+- `__init__` with config, encoders, cache
+- `_convert_value()` - type conversion (shared)
+- `_generate_text_embeddings()` - text embedding (shared)
+- `_generate_image_embeddings()` - image embedding (shared)
+- `_inject_tenant_id()` - tenant_id injection (shared)
+- `@abstractmethod transform()` - to be implemented by subclasses
+
+### 2.2 Refactor DataTransformer
+
+**File**: [`indexer/data_transformer.py`](indexer/data_transformer.py)
+
+Changes:
+
+- Inherit from `BaseDataTransformer`
+- Remove dependency on `source_table`, `source_column` from config
+- Accept field mapping as parameter (from script)
+- Implement `transform(df, field_mapping)` method
+
+### 2.3 Refactor SPUDataTransformer
+
+**File**: [`indexer/spu_data_transformer.py`](indexer/spu_data_transformer.py)
+
+Changes:
+
+- Inherit from `BaseDataTransformer`
+- Remove dependency on config's table names
+- Accept field mapping as parameter
+- Implement `transform(spu_df, sku_df, spu_field_mapping, sku_field_mapping)` method
+
+### 2.4 Create Transformer Factory
+
+**File**: [`indexer/transformer_factory.py`](indexer/transformer_factory.py) (NEW)
+
+Factory to create appropriate transformer based on parameters:
+
+```python
+class TransformerFactory:
+ @staticmethod
+ def create(
+ transformer_type: str, # 'sku' or 'spu'
+ config: CustomerConfig,
+ text_encoder=None,
+ image_encoder=None
+ ) -> BaseDataTransformer:
+ if transformer_type == 'spu':
+ return SPUDataTransformer(config, text_encoder, image_encoder)
+ elif transformer_type == 'sku':
+ return DataTransformer(config, text_encoder, image_encoder)
+ else:
+ raise ValueError(f"Unknown transformer type: {transformer_type}")
+```
+
+### 2.5 Update Package Exports
+
+**File**: [`indexer/__init__.py`](indexer/**init**.py)
+
+Export new structure:
+
+```python
+from .base_transformer import BaseDataTransformer
+from .data_transformer import DataTransformer
+from .spu_data_transformer import SPUDataTransformer
+from .transformer_factory import TransformerFactory
+
+__all__ = [
+ 'BaseDataTransformer',
+ 'DataTransformer',
+ 'SPUDataTransformer',
+ 'TransformerFactory', # Recommended for new code
+ 'BulkIndexer',
+ 'IndexingPipeline',
+]
+```
+
+## Phase 3: Script Refactoring
+
+### 3.1 Create Unified Ingestion Script
+
+**File**: [`scripts/ingest_universal.py`](scripts/ingest_universal.py) (NEW)
+
+Universal ingestion script with full parameter control:
+
+**Parameters**:
+
+```bash
+# Search configuration (pure)
+--config base # Which search config to use
+
+# Runtime parameters
+--tenant-id shop_12345 # REQUIRED tenant identifier
+--es-host http://localhost:9200
+--es-username elastic
+--es-password xxx
+
+# Data source parameters (pipeline concern)
+--data-source mysql # mysql, csv, api, etc.
+--mysql-host 120.79.247.228
+--mysql-port 3316
+--mysql-database saas
+--mysql-username saas
+--mysql-password xxx
+
+# Transformer parameters (pipeline concern)
+--transformer spu # spu or sku
+--spu-table shoplazza_product_spu
+--sku-table shoplazza_product_sku
+--shop-id 1 # Filter by shop_id
+
+# Field mapping (optional, uses defaults if not provided)
+--field-mapping mapping.json
+
+# Processing parameters
+--batch-size 100
+--limit 1000
+--skip-embeddings
+--recreate-index
+```
+
+**Logic**:
+
+1. Load search config (clean, no data source info)
+2. Set tenant_id from parameter
+3. Connect to data source based on `--data-source` parameter
+4. Load data from tables specified by parameters
+5. Create transformer based on `--transformer` parameter
+6. Apply field mapping (default or custom)
+7. Transform and index
+
+### 3.2 Update BASE Ingestion Script
+
+**File**: [`scripts/ingest_base.py`](scripts/ingest_base.py)
+
+Update to use script parameters instead of config values:
+
+- Remove dependency on `config.mysql_config`
+- Remove dependency on `config.main_table`, `config.sku_table`
+- Get all data source info from command-line arguments
+- Use TransformerFactory
+
+### 3.3 Create Field Mapping Helper
+
+**File**: [`scripts/field_mapping_generator.py`](scripts/field_mapping_generator.py) (NEW)
+
+Helper script to generate default field mappings:
+
+```python
+# Generate default mapping for Shoplazza SPU schema
+python scripts/field_mapping_generator.py \
+ --source shoplazza \
+ --level spu \
+ --output mappings/shoplazza_spu.json
+```
+
+Output example:
+
+```json
+{
+ "spu_fields": {
+ "id": "id",
+ "title": "title",
+ "description": "description",
+ ...
+ },
+ "sku_fields": {
+ "id": "id",
+ "price": "price",
+ "sku": "sku",
+ ...
+ }
+}
+```
+
+## Phase 4: Configuration Loader Updates
+
+### 4.1 Simplify ConfigLoader
+
+**File**: [`config/config_loader.py`](config/config_loader.py)
+
+Changes:
+
+- Remove parsing of `mysql_config`
+- Remove parsing of `main_table`, `sku_table`, `extension_table`
+- Remove validation of source_table/source_column in fields
+- Simplify field parsing (no source mapping)
+- Keep validation of ES/search related config
+
+### 4.2 Update CustomerConfig Model
+
+**File**: [`config/__init__.py`](config/**init**.py) or wherever CustomerConfig is defined
+
+Remove attributes:
+
+- `mysql_config`
+- `main_table`
+- `sku_table`
+- `extension_table`
+
+Add attributes:
+
+- `tenant_id` (runtime, default None)
+
+Simplify FieldConfig:
+
+- Remove `source_table`
+- Remove `source_column`
+
+## Phase 5: Documentation Updates
+
+### 5.1 Create Pipeline Guide
+
+**File**: [`docs/DATA_PIPELINE_GUIDE.md`](docs/DATA_PIPELINE_GUIDE.md) (NEW)
+
+Document:
+
+- Separation of concerns (config vs pipeline)
+- How to use `ingest_universal.py`
+- Default field mappings for common sources
+- Custom field mapping examples
+- Transformer selection guide
+
+### 5.2 Update BASE Config Guide
+
+**File**: [`docs/BASE_CONFIG_GUIDE.md`](docs/BASE_CONFIG_GUIDE.md)
+
+Update to reflect:
+
+- Config only contains search settings
+- No data source configuration
+- How tenant_id is injected at runtime
+- Examples of using same config with different data sources
+
+### 5.3 Update API Documentation
+
+**File**: [`API_DOCUMENTATION.md`](API_DOCUMENTATION.md)
+
+No changes needed (API layer doesn't know about data pipeline).
+
+### 5.4 Update Design Documentation
+
+**File**: [`设计文档.md`](设计文档.md)
+
+Add section on configuration architecture:
+
+- Clear separation between search config and pipeline
+- Benefits of this approach
+- How to extend for new data sources
+
+## Phase 6: Create Default Field Mappings
+
+### 6.1 Shoplazza SPU Mapping
+
+**File**: [`mappings/shoplazza_spu.json`](mappings/shoplazza_spu.json) (NEW)
+
+Default field mapping for Shoplazza SPU/SKU tables to BASE config fields.
+
+### 6.2 Shoplazza SKU Mapping (Legacy)
+
+**File**: [`mappings/shoplazza_sku_legacy.json`](mappings/shoplazza_sku_legacy.json) (NEW)
+
+Default field mapping for legacy SKU-level indexing.
+
+### 6.3 CSV Template Mapping
+
+**File**: [`mappings/csv_template.json`](mappings/csv_template.json) (NEW)
+
+Example mapping for CSV data sources.
+
+## Phase 7: Testing & Validation
+
+### 7.1 Test Script with Different Sources
+
+Test `ingest_universal.py` with:
+
+1. MySQL Shoplazza tables (SPU level)
+2. MySQL Shoplazza tables (SKU level, legacy)
+3. CSV files (if time permits)
+
+### 7.2 Verify Configuration Portability
+
+Test same BASE config with:
+
+- Different data sources
+- Different field mappings
+- Different transformers
+
+### 7.3 Update Test Scripts
+
+**File**: [`scripts/test_base.sh`](scripts/test_base.sh)
+
+Update to use new script parameters.
+
+## Phase 8: Migration & Cleanup
+
+### 8.1 Create Migration Guide
+
+**File**: [`docs/CONFIG_MIGRATION_GUIDE.md`](docs/CONFIG_MIGRATION_GUIDE.md) (NEW)
+
+Guide for migrating from old config format to new:
+
+- What changed
+- How to update existing configs
+- How to update ingestion scripts
+- Breaking changes
+
+### 8.2 Update Example Configs
+
+Update all example configurations to new format.
+
+### 8.3 Mark Old Scripts as Deprecated
+
+Add deprecation warnings to scripts that still use old config format.
+
+## Key Design Principles
+
+### 1. Separation of Concerns
+
+**Search Configuration** (customer-facing):
+
+- What fields exist in ES
+- How fields are analyzed/indexed
+- Search strategies and ranking
+- Facets and aggregations
+- Query processing rules
+
+**Data Pipeline** (internal):
+
+- Where data comes from
+- How to connect to data sources
+- Which tables/files to read
+- How to transform data
+- Field mapping logic
+
+### 2. Configuration Portability
+
+Same search config can be used with:
+
+- Different data sources (MySQL, CSV, API)
+- Different schemas (with appropriate mapping)
+- Different transformation strategies
+
+### 3. Flexibility
+
+Pipeline decisions (transformer, data source, field mapping) made at runtime, not in config.
+
+## Migration Path
+
+### For Existing Users
+
+1. Update config files (remove data source settings)
+2. Update ingestion commands (add new parameters)
+3. Optionally create field mapping files for convenience
+
+### For New Users
+
+1. Copy BASE config (already clean)
+2. Run `ingest_universal.py` with appropriate parameters
+3. Provide custom field mapping if needed
+
+## Success Criteria
+
+- [ ] BASE config contains ZERO data source information
+- [ ] Same config works with MySQL and CSV sources
+- [ ] Pipeline fully controlled by script parameters
+- [ ] Transformers work with external field mapping
+- [ ] Documentation clearly separates concerns
+- [ ] Tests validate portability
+- [ ] Migration guide provided
+
+## Estimated Effort
+
+- Configuration cleanup: 2 hours
+- Transformer refactoring: 4-5 hours
+- Script refactoring: 3-4 hours
+- Config loader updates: 2 hours
+- Documentation: 2-3 hours
+- Testing & validation: 2-3 hours
+- **Total: 15-19 hours**
+
+## Benefits
+
+✅ **Clean separation of concerns**
+
+✅ **Configuration reusability across data sources**
+
+✅ **Customer doesn't need to understand ETL**
+
+✅ **Easier to add new data sources**
+
+✅ **More flexible pipeline control**
+
+✅ **Reduced configuration complexity**
+
+### To-dos
+
+- [ ] Clean BASE and legacy configs: remove mysql_config, table names, source_table/source_column from fields
+- [ ] Create BaseDataTransformer abstract class with shared logic (type conversion, embeddings, tenant_id)
+- [ ] Refactor DataTransformer and SPUDataTransformer to inherit from base, accept field mapping as parameter
+- [ ] Create TransformerFactory for creating transformers based on type parameter
+- [ ] Create ingest_universal.py with full parameter control for data source, transformer, field mapping
+- [ ] Update scripts/ingest_base.py to use parameters instead of config for data source
+- [ ] Create field_mapping_generator.py and default mapping files (shoplazza_spu.json, etc.)
+- [ ] Simplify ConfigLoader to only parse search config, remove data source parsing
+- [ ] Create DATA_PIPELINE_GUIDE.md documenting pipeline approach and config separation
+- [ ] Update BASE_CONFIG_GUIDE.md to reflect config-only-search-settings approach
+- [ ] Create CONFIG_MIGRATION_GUIDE.md for migrating from old to new config format
+- [ ] Test same config with different data sources and validate portability
\ No newline at end of file
diff --git a/.cursor/plans/所有tenant按同一份所有_返回接口优化.md b/.cursor/plans/所有tenant按同一份所有_返回接口优化.md
new file mode 100644
index 0000000..693ae80
--- /dev/null
+++ b/.cursor/plans/所有tenant按同一份所有_返回接口优化.md
@@ -0,0 +1,302 @@
+
+# SPU-Level Indexing with Shoplazza API Format & BASE Configuration
+
+## Phase 1: Schema Analysis & Design
+
+### 1.1 Analyze SPU/SKU Fields for Indexing
+
+**SPU Fields** (from `shoplazza_product_spu`):
+
+- **Index + Store**: `id`, `shop_id`, `handle`, `title`, `brief`, `vendor`, `product_type`, `tags`, `category`, `image_src`, `published`, `published_at`
+- **Index only**: `seo_title`, `seo_description`, `seo_keywords`
+- **Store only**: `description` (HTML, no tokenization)
+
+**SKU Fields** (from `shoplazza_product_sku`, as nested array):
+
+- **Index + Store**: `id`, `title`, `sku`, `barcode`, `price`, `compare_at_price`, `option1`, `option2`, `option3`, `inventory_quantity`, `image_src`
+
+**Price Strategy (CONFIRMED - Option B)**:
+
+- Flatten `min_price`, `max_price`, `compare_at_price` at SPU level for fast filtering/sorting
+- Keep full variant prices in nested array for display
+
+### 1.2 Design BASE Schema
+
+**Index**: `search_products` (shared by all tenants)
+
+**Key fields**:
+
+- `tenant_id` (KEYWORD, **REQUIRED**) - always filtered, never optional
+- SPU-level flattened fields
+- `variants` (NESTED array) - SKU data
+- Flattened: `min_price`, `max_price`, `compare_at_price`
+- Multi-language: `title_zh`, `title_en`, `title_ru`, etc.
+- Embeddings: `title_embedding`, `image_embedding`
+
+## Phase 2: BASE Configuration (Universal Standard)
+
+### 2.1 Create BASE Config
+
+**File**: [`config/schema/base/config.yaml`](config/schema/base/config.yaml)
+
+**This is the universal configuration for ALL merchants using Shoplazza tables.**
+
+Key points:
+
+- Index name: `search_products` (shared)
+- Required field: `tenant_id` (always filtered in queries)
+- SPU-level fields with multi-language support
+- Nested variants structure
+- Flattened price fields: `min_price`, `max_price`, `compare_at_price`
+- Function_score configuration
+
+### 2.2 Update Field Types & Mapping
+
+**Files**: [`config/field_types.py`](config/field_types.py), [`indexer/mapping_generator.py`](indexer/mapping_generator.py)
+
+- Add `NESTED` field type
+- Handle nested mapping generation for variants
+- Auto-generate flattened price fields
+
+## Phase 3: Data Ingestion for BASE
+
+### 3.1 SPU-Level Data Transformer
+
+**File**: [`indexer/spu_data_transformer.py`](indexer/spu_data_transformer.py)
+
+Features:
+
+- Load SPU from `shoplazza_product_spu`
+- Join SKU from `shoplazza_product_sku` (grouped by spu_id)
+- Create nested variants array
+- Calculate `min_price`, `max_price`, `compare_at_price`
+- Generate title & image embeddings
+- Inject `tenant_id` from config
+
+### 3.2 Test Data Generator
+
+**File**: [`scripts/generate_shoplazza_test_data.py`](scripts/generate_shoplazza_test_data.py)
+
+Generate 100 SPU records with:
+
+- 10 categories, multiple vendors
+- Multi-language (zh/en/ru)
+- Price range: $5-$500
+- 1-5 variants per SPU (color, size options)
+- Insert into MySQL Shoplazza tables
+
+### 3.3 BASE Ingestion Script
+
+**File**: [`scripts/ingest_base.py`](scripts/ingest_base.py)
+
+- Load from MySQL `shoplazza_product_spu` + `shoplazza_product_sku`
+- Use `SPUDataTransformer`
+- Index into `search_products` with configured `tenant_id`
+
+## Phase 4: Query Updates
+
+### 4.1 Query Builder Enhancements
+
+**File**: [`search/multilang_query_builder.py`](search/multilang_query_builder.py)
+
+- **Auto-inject `tenant_id` filter** (from config, always applied)
+- Support nested queries for variants
+- Use flattened price fields for filters: `min_price`, `max_price`
+
+### 4.2 Searcher Updates
+
+**File**: [`search/searcher.py`](search/searcher.py)
+
+- Enforce `tenant_id` filtering
+- Handle nested inner_hits for variants
+
+## Phase 5: API Response Transformation
+
+### 5.1 Response Transformer
+
+**File**: [`api/response_transformer.py`](api/response_transformer.py)
+
+Transform ES response to Shoplazza format:
+
+- Extract variants from nested array
+- Map fields: `product_id`, `title`, `handle`, `vendor`, `product_type`, `tags`, `price`, `variants`, etc.
+- Calculate `in_stock` from variants
+
+### 5.2 Update API Models
+
+**File**: [`api/models.py`](api/models.py)
+
+New models:
+
+- `VariantOption`, `ProductVariant`, `ProductResult`
+- Updated `SearchResponse` with `results: List[ProductResult]`
+- Add placeholders: `suggestions: List[str] = []`, `related_searches: List[str] = []`
+
+### 5.3 Update Search Routes
+
+**File**: [`api/routes/search.py`](api/routes/search.py)
+
+- Use `ResponseTransformer` to convert ES hits
+- Return new Shoplazza-compatible format
+- **Ensure `tenant_id` is required in request**
+
+## Phase 6: Legacy Migration
+
+### 6.1 Rename Customer1 to Legacy
+
+- Rename [`config/schema/customer1/`](config/schema/customer1/) to [`config/schema/customer1_legacy/`](config/schema/customer1_legacy/)
+- Update config to use old index `search_customer1` (preserve for backward compatibility)
+- Mark as deprecated in comments
+
+### 6.2 Update Scripts for BASE
+
+- [`run.sh`](run.sh): Use BASE config, `search_products` index
+- [`restart.sh`](restart.sh): Use BASE config
+- [`test_all.sh`](test_all.sh): Test BASE config
+- Legacy scripts: Rename with `_legacy` suffix (e.g., `run_legacy.sh`)
+
+### 6.3 Update Frontend
+
+**Files**: [`frontend/`](frontend/) HTML/JS files
+
+- Change index name references from `search_customer1` to `search_products`
+- Use BASE config endpoints
+- Archive old frontend as `frontend_legacy/` if needed
+
+## Phase 7: API Documentation Updates
+
+### 7.1 Update API Docs
+
+**File**: [`API_DOCUMENTATION.md`](API_DOCUMENTATION.md)
+
+**Critical additions**:
+
+- **Document `tenant_id` as REQUIRED parameter** in all search requests
+- Explain that `tenant_id` filter is always applied
+- Update all response examples to new Shoplazza format
+- Document `suggestions` and `related_searches` (not yet implemented)
+- Add nested variant query examples
+- Multi-tenant isolation guarantees
+
+### 7.2 Update Request Models
+
+**File**: [`api/models.py`](api/models.py)
+
+Add `tenant_id` to `SearchRequest`:
+
+```python
+class SearchRequest(BaseModel):
+ tenant_id: str = Field(..., description="租户ID (必需)")
+ query: str = Field(...)
+ # ... other fields
+```
+
+## Phase 8: Design Documentation
+
+### 8.1 Update Design Doc
+
+**File**: [`设计文档.md`](设计文档.md)
+
+Updates:
+
+- **索引粒度**: 改为 SPU 维度(非SKU)
+- **统一索引**: 所有租户共用 `search_products`,通过 `tenant_id` 隔离
+- **BASE配置**: 说明BASE配置为通用标准,所有新商户使用
+- **API响应格式**: 采用 Shoplazza 标准格式
+- **Price扁平化**: 说明高频字段的性能优化策略
+- **Nested变体**: 详细说明 variants 数组结构
+- **Legacy配置**: customer1等为遗留配置,仅用于兼容
+
+### 8.2 Create BASE Guide
+
+**File**: [`docs/BASE_CONFIG_GUIDE.md`](docs/BASE_CONFIG_GUIDE.md)
+
+Contents:
+
+- BASE configuration overview
+- How to generate test data
+- How to run ingestion for new tenant
+- Search examples
+- Response format examples
+- Multi-tenant isolation
+
+### 8.3 Create Migration Guide
+
+**File**: [`docs/MIGRATION_TO_BASE.md`](docs/MIGRATION_TO_BASE.md)
+
+- Breaking changes from SKU-level to SPU-level
+- Response format changes
+- How existing deployments should migrate
+- Legacy config deprecation timeline
+
+## Phase 9: Testing
+
+### 9.1 Create Test Script
+
+**File**: [`scripts/test_base.sh`](scripts/test_base.sh)
+
+Steps:
+
+1. Generate 100 test SPU records
+2. Run BASE ingestion with tenant_id="test_tenant"
+3. Run searches, verify response format
+4. Test faceted search
+5. Verify multi-tenant isolation
+6. Verify `tenant_id` filtering
+
+### 9.2 Integration Tests
+
+**File**: [`tests/test_base_integration.py`](tests/test_base_integration.py)
+
+- Test SPU-level indexing
+- Test nested variant queries
+- Test price filtering with flattened fields
+- Test tenant_id isolation
+- Test response transformation
+
+## Key Architectural Decisions
+
+### BASE Configuration Philosophy
+
+**BASE = Universal Standard**: All new merchants use BASE config with Shoplazza tables. No per-customer schema customization. Customization happens through:
+
+- Configuration parameters (analyzers, function_score, etc.)
+- Extension tables (if needed for additional fields)
+- NOT through separate schemas
+
+### Tenant Isolation
+
+**tenant_id is SACRED**:
+
+- Always present in queries (enforced at query builder)
+- Never optional
+- Guarantees data isolation between tenants
+- Documented prominently in API docs
+
+### Price Flattening Rationale
+
+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.
+
+### Legacy vs BASE
+
+- **BASE**: New standard, all future merchants
+- **Legacy (customer1_legacy)**: Deprecated, exists only for backward compatibility
+- All scripts/frontend default to BASE
+- Legacy access requires explicit suffix (`_legacy`)
+
+### To-dos
+
+- [ ] Analyze SPU/SKU fields and design unified schema with tenant_id, nested variants, and flattened price fields
+- [ ] Create unified schema config for multi-tenant SPU-level indexing
+- [ ] Add NESTED field type support to field_types.py and mapping generator
+- [ ] Create SPU-level data transformer that joins SPU+SKU tables and creates nested variant array
+- [ ] Create script to generate 100 realistic SPU+SKU test records in Shoplazza tables
+- [ ] Create customer2 configuration using unified schema and Shoplazza tables only
+- [ ] Create customer2 ingestion script that loads from MySQL Shoplazza tables
+- [ ] Update query builder to support tenant_id filtering and nested variant queries
+- [ ] Create response transformer to convert ES format to Shoplazza-compatible format
+- [ ] Update API models with new Shoplazza response format (ProductResult, variants, suggestions, etc.)
+- [ ] Update search routes to use response transformer and return new format
+- [ ] Migrate customer1 configuration to unified schema and SPU-level indexing
+- [ ] Create customer2 guide, update design docs, API docs, and create migration guide
+- [ ] Create comprehensive test script for customer2 with data generation, ingestion, and search validation
\ No newline at end of file
diff --git a/SHOPLAZZA_INTEGRATION_GUIDE.md b/SHOPLAZZA_INTEGRATION_GUIDE.md
deleted file mode 100644
index 71fffeb..0000000
--- a/SHOPLAZZA_INTEGRATION_GUIDE.md
+++ /dev/null
@@ -1,3226 +0,0 @@
-# 店匠平台技术对接指南
-
-## 1. 概述
-
-### 1.1 店匠平台介绍
-
-[店匠(Shoplazza)](https://www.shoplazza.com) 是一个专为跨境电商设计的独立站建站平台,类似于 Shopify。商家可以快速搭建自己的品牌独立站,进行商品销售、订单管理、客户管理等运营。
-
-店匠提供了开放的应用生态系统,第三方开发者可以开发应用插件(APP)并发布到店匠应用市场,为商家提供增值服务。
-
-**核心特性:**
-- 独立站建站和主题装修
-- 商品、订单、客户管理
-- 多语言和多货币支持
-- 开放的 Admin API
-- Webhook 事件通知
-- OAuth 2.0 授权机制
-
-### 1.2 对接目标
-
-本文档旨在帮助开发团队将**搜索 SaaS** 接入店匠生态,作为应用市场的搜索插件上线。
-
-**对接目标:**
-1. 在店匠应用市场发布搜索 APP
-2. 商家可以安装 APP 并授权访问店铺数据
-3. 自动同步商家的商品、订单、客户数据
-4. 提供前端搜索扩展,嵌入商家的店铺主题
-5. 为商家提供智能搜索服务(多语言、语义搜索、AI 搜索)
-6. 统计分析商家的搜索行为数据
-
-### 1.3 系统架构
-
-```mermaid
-graph TB
- subgraph "店匠平台"
- A[店匠应用市场]
- B[商家店铺]
- C[店匠 Admin API]
- D[店匠 Webhook]
- end
-
- subgraph "搜索 SaaS 平台"
- E[OAuth 服务]
- F[数据同步服务]
- G[Webhook 接收服务]
- H[搜索 API 服务]
- I[管理后台]
- J[数据库
MySQL]
- K[搜索引擎
Elasticsearch]
- end
-
- subgraph "前端扩展"
- L[搜索入口组件]
- M[搜索结果页]
- end
-
- A -->|商家安装| E
- B -->|OAuth授权| E
- E -->|获取Token| F
- F -->|调用API| C
- C -->|返回数据| F
- F -->|存储| J
- F -->|索引| K
- D -->|推送事件| G
- G -->|增量更新| J
- G -->|增量索引| K
- B -->|装修主题| L
- L -->|搜索请求| H
- M -->|搜索请求| H
- H -->|查询| K
- I -->|管理| J
-```
-
-### 1.4 技术栈要求
-
-**后端服务:**
-- Java(Spring Boot):OAuth、数据同步、API 网关
-- Python(FastAPI):搜索服务、向量检索
-- MySQL:存储店铺、商品、订单等数据
-- Elasticsearch:商品索引和搜索
-
-**前端扩展:**
-- Liquid 模板语言(店匠主题)
-- JavaScript/TypeScript
-- HTML/CSS
-
-**基础设施:**
-- 公网域名(支持 HTTPS)
-- SSL 证书
-- 服务器(支持 Docker 部署)
-
-### 1.5 前置条件
-
-在开始对接之前,请确保:
-
-1. ✅ 已注册店匠 Partner 账号
-2. ✅ 拥有公网域名和 HTTPS 证书
-3. ✅ 已部署搜索 SaaS 后端服务
-4. ✅ 拥有测试店铺(用于开发和调试)
-5. ✅ 熟悉 OAuth 2.0 授权流程
-6. ✅ 熟悉 RESTful API 开发
-
----
-
-## 2. 开发者准备
-
-### 2.1 注册店匠 Partner 账号
-
-1. 访问 [店匠合作伙伴中心](https://partners.shoplazza.com)
-2. 点击"注册"按钮,填写公司信息
-3. 完成邮箱验证和资质审核
-4. 登录 Partner 后台
-
-### 2.2 创建 APP 应用
-
-1. 登录 [店匠 Partner 后台](https://partners.shoplazza.com)
-2. 在左侧导航栏选择"Apps"
-3. 点击"Create App"按钮
-4. 填写 APP 基本信息:
- - **App Name**:搜索 SaaS(或自定义名称)
- - **App Type**:Public App(公开应用)
- - **Category**:Search & Discovery(搜索与发现)
-
-5. 系统自动生成:
- - **Client ID**:应用的唯一标识
- - **Client Secret**:应用密钥(请妥善保管)
-
-### 2.3 配置 APP 信息
-
-在 APP 设置页面,配置以下关键信息:
-
-#### 2.3.1 OAuth 配置
-
-```yaml
-Client ID: m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es
-Client Secret: m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo
-```
-
-**重定向 URI(Redirect URI):**
-```
-https://your-domain.com/oauth/callback
-```
-
-**注意事项:**
-- Redirect URI 必须使用 HTTPS 协议
-- 必须是公网可访问的地址
-- 开发环境可以使用 ngrok 等工具暴露本地服务
-
-#### 2.3.2 应用权限(Scopes)
-
-根据业务需求,申请以下权限:
-
-| 权限 Scope | 说明 | 是否必需 |
-|------------|------|----------|
-| `read_shop` | 读取店铺信息 | ✅ 必需 |
-| `write_shop` | 修改店铺信息 | ❌ 可选 |
-| `read_product` | 读取商品信息 | ✅ 必需 |
-| `write_product` | 修改商品信息 | ❌ 可选 |
-| `read_order` | 读取订单信息 | ✅ 必需 |
-| `read_customer` | 读取客户信息 | ✅ 必需 |
-| `read_app_proxy` | APP 代理访问 | ✅ 必需 |
-| `write_cart_transform` | 购物车转换(如需价格调整) | ❌ 可选 |
-
-**配置示例:**
-```go
-Scopes: []string{
- "read_shop",
- "read_product",
- "read_order",
- "read_customer",
- "read_app_proxy",
-}
-```
-
-#### 2.3.3 Webhook 配置(后续注册)
-
-Webhook 地址(后续在代码中动态注册):
-```
-https://your-domain.com/webhook/shoplazza
-```
-
-### 2.4 准备测试店铺
-
-1. 在店匠平台注册一个测试店铺
-2. 在店铺中添加测试商品、客户、订单数据
-3. 记录店铺域名:`{shop-name}.myshoplaza.com`
-
-**注意:** 部分功能(如 Webhook 注册)需要店铺激活后才能使用。
-
----
-
-## 3. OAuth 2.0 认证实现
-
-### 3.1 OAuth 授权流程
-
-店匠使用标准的 OAuth 2.0 授权码(Authorization Code)流程:
-
-```mermaid
-sequenceDiagram
- participant 商家
- participant 店匠平台
- participant 搜索SaaS
-
- 商家->>店匠平台: 1. 在应用市场点击"安装"
- 店匠平台->>搜索SaaS: 2. 跳转到 APP URI
- 搜索SaaS->>店匠平台: 3. 重定向到授权页面
- 店匠平台->>商家: 4. 显示授权确认页
- 商家->>店匠平台: 5. 点击"授权"
- 店匠平台->>搜索SaaS: 6. 回调 Redirect URI(带 code)
- 搜索SaaS->>店匠平台: 7. 用 code 换取 Access Token
- 店匠平台->>搜索SaaS: 8. 返回 Access Token
- 搜索SaaS->>搜索SaaS: 9. 保存 Token 到数据库
- 搜索SaaS->>商家: 10. 显示安装成功页面
-```
-
-### 3.2 实现步骤
-
-#### 3.2.1 配置 OAuth 客户端
-
-在应用启动时,初始化 OAuth 配置:
-
-```go
-// OAuth 配置
-type OAuthConfig struct {
- ClientID string
- ClientSecret string
- RedirectURI string
- Scopes []string
- AuthURL string
- TokenURL string
-}
-
-// 初始化配置
-config := &OAuthConfig{
- ClientID: "m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es",
- ClientSecret: "m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo",
- RedirectURI: "https://your-domain.com/oauth/callback",
- Scopes: []string{
- "read_shop",
- "read_product",
- "read_order",
- "read_customer",
- "read_app_proxy",
- },
- AuthURL: "https://partners.shoplazza.com/partner/oauth/authorize",
- TokenURL: "https://partners.shoplazza.com/partner/oauth/token",
-}
-```
-
-#### 3.2.2 处理 APP URI 请求
-
-当商家在应用市场点击"安装"时,店匠会跳转到你配置的 APP URI:
-
-```
-GET https://your-domain.com/oauth/install?shop={shop_domain}
-```
-
-**处理逻辑:**
-
-```http
-GET /oauth/install
-Query Parameters:
- - shop: 店铺域名,例如 47167113-1.myshoplaza.com
-
-Response:
- 302 Redirect to Authorization URL
-```
-
-**生成授权 URL:**
-
-```go
-// 构建授权 URL
-func buildAuthURL(config *OAuthConfig, shop string) string {
- params := url.Values{}
- params.Add("client_id", config.ClientID)
- params.Add("redirect_uri", config.RedirectURI)
- params.Add("scope", strings.Join(config.Scopes, " "))
- params.Add("state", shop) // 使用 shop 作为 state
-
- return config.AuthURL + "?" + params.Encode()
-}
-```
-
-**授权 URL 示例:**
-```
-https://partners.shoplazza.com/partner/oauth/authorize?
- client_id=m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es
- &redirect_uri=https://your-domain.com/oauth/callback
- &scope=read_shop read_product read_order read_customer read_app_proxy
- &state=47167113-1.myshoplaza.com
-```
-
-#### 3.2.3 处理授权回调
-
-商家授权后,店匠会回调你的 Redirect URI:
-
-```
-GET https://your-domain.com/oauth/callback?code={auth_code}&shop={shop_domain}&state={state}
-```
-
-**回调参数:**
-- `code`:授权码(用于换取 Access Token)
-- `shop`:店铺域名
-- `state`:之前传递的 state 参数
-
-**处理逻辑:**
-
-```http
-GET /oauth/callback
-Query Parameters:
- - code: 授权码
- - shop: 店铺域名
- - state: state 参数
-
-Response:
- 200 OK (HTML 页面显示安装成功)
-```
-
-#### 3.2.4 换取 Access Token
-
-使用授权码换取 Access Token:
-
-**请求示例(curl):**
-
-```bash
-curl --request POST \
- --url https://partners.shoplazza.com/partner/oauth/token \
- --header 'Content-Type: application/json' \
- --data '{
- "client_id": "m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es",
- "client_secret": "m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo",
- "code": "{authorization_code}",
- "grant_type": "authorization_code",
- "redirect_uri": "https://your-domain.com/oauth/callback"
- }'
-```
-
-**响应示例:**
-
-```json
-{
- "access_token": "V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY",
- "token_type": "Bearer",
- "refresh_token": "-QP6o5YpsqC47q5D2M3xHJ0YP4SPcybhm5oYlPaMUOo",
- "expires_in": 31556951,
- "created_at": 1740793402,
- "store_id": "2286274",
- "store_name": "47167113-1",
- "expires_at": 1772350354,
- "locale": "zh-CN"
-}
-```
-
-**响应字段说明:**
-- `access_token`:访问令牌(调用 Admin API 时使用)
-- `refresh_token`:刷新令牌(用于刷新 Access Token)
-- `expires_in`:过期时间(秒)
-- `expires_at`:过期时间戳
-- `store_id`:店铺 ID
-- `store_name`:店铺名称
-- `locale`:店铺语言
-
-#### 3.2.5 保存 Token 到数据库
-
-将 Token 信息保存到数据库(详见第 4 章数据模型):
-
-```sql
-INSERT INTO shoplazza_shop_config (
- store_id,
- store_name,
- store_domain,
- access_token,
- refresh_token,
- token_expires_at,
- locale,
- status,
- created_at,
- updated_at
-) VALUES (
- '2286274',
- '47167113-1',
- '47167113-1.myshoplaza.com',
- 'V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY',
- '-QP6o5YpsqC47q5D2M3xHJ0YP4SPcybhm5oYlPaMUOo',
- '2026-11-02 23:21:14',
- 'zh-CN',
- 'active',
- NOW(),
- NOW()
-);
-```
-
-### 3.3 Token 刷新机制
-
-Access Token 会过期(通常为 1 年),过期后需要使用 Refresh Token 刷新。
-
-**刷新 Token 请求:**
-
-```bash
-curl --request POST \
- --url https://partners.shoplazza.com/partner/oauth/token \
- --header 'Content-Type: application/json' \
- --data '{
- "client_id": "m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es",
- "client_secret": "m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo",
- "refresh_token": "-QP6o5YpsqC47q5D2M3xHJ0YP4SPcybhm5oYlPaMUOo",
- "grant_type": "refresh_token"
- }'
-```
-
-**响应格式与获取 Token 时相同。**
-
-**刷新策略:**
-1. 在 Token 过期前 7 天开始尝试刷新
-2. API 调用返回 401 Unauthorized 时立即刷新
-3. 刷新成功后更新数据库中的 Token 信息
-
-### 3.4 安装成功页面
-
-OAuth 回调处理完成后,返回一个 HTML 页面告知商家安装成功:
-
-```html
-
-
-
-
- 安装成功 - 搜索 SaaS
-
-
-
- ✓
- 安装成功!
- 搜索 SaaS 已成功安装到您的店铺
- 店铺名称:{{store_name}}
-
-
-
下一步操作:
-
- - 进入店铺后台 → 主题装修
- - 点击"添加卡片" → 选择"APPS" → 找到"搜索 SaaS"
- - 拖拽搜索组件到页面中
- - 保存并发布主题
-
-
-
- 前往店铺后台
-
-
-```
-
----
-
-## 4. 租户和店铺管理
-
-### 4.1 数据模型设计
-
-#### 4.1.1 租户表(system_tenant)
-
-每个店铺在 SaaS 平台都是一个独立的租户。
-
-```sql
-CREATE TABLE `system_tenant` (
- `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '租户ID',
- `name` VARCHAR(255) NOT NULL COMMENT '租户名称',
- `package_id` BIGINT DEFAULT NULL COMMENT '套餐ID',
- `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
- `expire_time` DATETIME DEFAULT NULL COMMENT '过期时间',
- `account_count` INT DEFAULT 0 COMMENT '账号数量',
- `creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
- `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
- `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
- PRIMARY KEY (`id`),
- KEY `idx_name` (`name`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户表';
-```
-
-#### 4.1.2 店铺配置表(shoplazza_shop_config)
-
-存储店铺的基本信息和 OAuth Token。
-
-```sql
-CREATE TABLE `shoplazza_shop_config` (
- `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
- `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
- `store_id` VARCHAR(64) NOT NULL COMMENT '店匠店铺ID',
- `store_name` VARCHAR(255) NOT NULL COMMENT '店铺名称',
- `store_domain` VARCHAR(255) NOT NULL COMMENT '店铺域名',
- `access_token` VARCHAR(512) NOT NULL COMMENT 'Access Token',
- `refresh_token` VARCHAR(512) DEFAULT NULL COMMENT 'Refresh Token',
- `token_expires_at` DATETIME NOT NULL COMMENT 'Token过期时间',
- `locale` VARCHAR(16) DEFAULT 'zh-CN' COMMENT '店铺语言',
- `currency` VARCHAR(16) DEFAULT 'USD' COMMENT '店铺货币',
- `timezone` VARCHAR(64) DEFAULT 'Asia/Shanghai' COMMENT '店铺时区',
- `status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active-激活,inactive-未激活,suspended-暂停',
- `install_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '安装时间',
- `last_sync_time` DATETIME DEFAULT NULL COMMENT '最后同步时间',
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_store_id` (`store_id`),
- KEY `idx_tenant_id` (`tenant_id`),
- KEY `idx_store_domain` (`store_domain`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠店铺配置表';
-```
-
-### 4.2 Token 管理策略
-
-#### 4.2.1 Token 存储
-
-- ✅ 加密存储 Access Token 和 Refresh Token
-- ✅ 记录 Token 过期时间
-- ✅ 记录最后刷新时间
-
-#### 4.2.2 Token 自动刷新
-
-```java
-public class TokenRefreshService {
-
- /**
- * 检查并刷新即将过期的 Token
- */
- @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
- public void refreshExpiringTokens() {
- // 查询7天内过期的 Token
- DateTime sevenDaysLater = DateTime.now().plusDays(7);
- List shops = shopConfigMapper.selectExpiringTokens(sevenDaysLater);
-
- for (ShopConfig shop : shops) {
- try {
- // 刷新 Token
- TokenResponse newToken = oauthClient.refreshToken(shop.getRefreshToken());
-
- // 更新数据库
- shop.setAccessToken(newToken.getAccessToken());
- shop.setRefreshToken(newToken.getRefreshToken());
- shop.setTokenExpiresAt(newToken.getExpiresAt());
- shopConfigMapper.updateById(shop);
-
- log.info("Token refreshed for shop: {}", shop.getStoreName());
- } catch (Exception e) {
- log.error("Failed to refresh token for shop: {}", shop.getStoreName(), e);
- // 可选:发送告警通知
- }
- }
- }
-
- /**
- * API 调用时检查 Token 是否过期
- */
- public String getValidAccessToken(String storeId) {
- ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
-
- if (shop == null) {
- throw new BusinessException("Shop not found: " + storeId);
- }
-
- // 检查是否即将过期(提前1小时)
- if (shop.getTokenExpiresAt().isBefore(DateTime.now().plusHours(1))) {
- // 刷新 Token
- TokenResponse newToken = oauthClient.refreshToken(shop.getRefreshToken());
-
- // 更新数据库
- shop.setAccessToken(newToken.getAccessToken());
- shop.setRefreshToken(newToken.getRefreshToken());
- shop.setTokenExpiresAt(newToken.getExpiresAt());
- shopConfigMapper.updateById(shop);
- }
-
- return shop.getAccessToken();
- }
-}
-```
-
-### 4.3 租户创建流程
-
-当商家完成 OAuth 授权后,自动创建租户和店铺配置:
-
-```java
-@Transactional
-public void handleOAuthCallback(TokenResponse tokenResponse) {
- String storeId = tokenResponse.getStoreId();
- String storeName = tokenResponse.getStoreName();
-
- // 1. 检查租户是否已存在
- Tenant tenant = tenantMapper.selectByStoreId(storeId);
- if (tenant == null) {
- // 创建新租户
- tenant = new Tenant();
- tenant.setName(storeName);
- tenant.setStatus(1); // 启用
- tenant.setPackageId(1L); // 默认套餐
- tenantMapper.insert(tenant);
- }
-
- // 2. 创建或更新店铺配置
- ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
- if (shop == null) {
- shop = new ShopConfig();
- shop.setTenantId(tenant.getId());
- shop.setStoreId(storeId);
- shop.setStoreName(storeName);
- }
-
- // 更新 Token 信息
- shop.setAccessToken(tokenResponse.getAccessToken());
- shop.setRefreshToken(tokenResponse.getRefreshToken());
- shop.setTokenExpiresAt(tokenResponse.getExpiresAt());
- shop.setLocale(tokenResponse.getLocale());
- shop.setStatus("active");
- shop.setInstallTime(new Date());
-
- if (shop.getId() == null) {
- shopConfigMapper.insert(shop);
- } else {
- shopConfigMapper.updateById(shop);
- }
-
- // 3. 触发首次数据同步
- dataSyncService.syncAllData(shop.getId());
-}
-```
-
----
-
-## 5. 店匠 Admin API 调用
-
-### 5.1 API 认证方式
-
-调用店匠 Admin API 时,需要在请求头中携带 Access Token:
-
-```http
-GET /openapi/2022-01/products
-Host: {shop-domain}.myshoplaza.com
-access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY
-Content-Type: application/json
-```
-
-**注意:** 请求头字段名是 `access-token`,不是 `Authorization`。
-
-### 5.2 API 端点基础 URL
-
-店匠 API 的基础 URL 格式:
-
-```
-https://{shop-domain}.myshoplaza.com/openapi/{version}/{resource}
-```
-
-**参数说明:**
-- `{shop-domain}`:店铺域名,例如 `47167113-1`
-- `{version}`:API 版本,目前为 `2022-01`
-- `{resource}`:资源路径,例如 `products`、`orders`
-
-**示例:**
-```
-https://47167113-1.myshoplaza.com/openapi/2022-01/products
-```
-
-### 5.3 常用 API 端点
-
-#### 5.3.1 店铺信息
-
-```bash
-# 获取店铺详情
-GET /openapi/2022-01/shop
-```
-
-#### 5.3.2 商品管理
-
-```bash
-# 获取商品列表
-GET /openapi/2022-01/products?page=1&limit=50
-
-# 获取商品详情
-GET /openapi/2022-01/products/{product_id}
-
-# 获取商品总数
-GET /openapi/2022-01/products/count
-```
-
-#### 5.3.3 订单管理
-
-```bash
-# 获取订单列表
-GET /openapi/2022-01/orders?page=1&limit=50
-
-# 获取订单详情
-GET /openapi/2022-01/orders/{order_id}
-
-# 获取订单总数
-GET /openapi/2022-01/orders/count
-```
-
-#### 5.3.4 客户管理
-
-```bash
-# 获取客户列表
-GET /openapi/2022-01/customers?page=1&limit=50
-
-# 获取客户详情
-GET /openapi/2022-01/customers/{customer_id}
-
-# 获取客户总数
-GET /openapi/2022-01/customers/count
-```
-
-### 5.4 请求和响应格式
-
-#### 5.4.1 分页查询
-
-店匠 API 使用基于页码的分页:
-
-```http
-GET /openapi/2022-01/products?page=1&limit=50&status=active
-```
-
-**分页参数:**
-- `page`:页码,从 1 开始
-- `limit`:每页数量,最大 250
-
-**响应格式:**
-```json
-{
- "products": [
- {
- "id": "123456",
- "title": "Product Name",
- "variants": [...],
- ...
- }
- ]
-}
-```
-
-#### 5.4.2 错误响应
-
-API 调用失败时返回错误信息:
-
-```json
-{
- "error": "Unauthorized",
- "error_description": "Invalid access token"
-}
-```
-
-**常见错误码:**
-- `400 Bad Request`:请求参数错误
-- `401 Unauthorized`:Token 无效或过期
-- `403 Forbidden`:权限不足
-- `404 Not Found`:资源不存在
-- `429 Too Many Requests`:触发速率限制
-- `500 Internal Server Error`:服务器错误
-
-### 5.5 错误处理和重试策略
-
-```java
-public class ShoplazzaApiClient {
-
- private static final int MAX_RETRIES = 3;
- private static final int RETRY_DELAY_MS = 1000;
-
- /**
- * 调用 API 并处理错误
- */
- public T callApi(String storeId, String endpoint, Class responseType) {
- int retries = 0;
- Exception lastException = null;
-
- while (retries < MAX_RETRIES) {
- try {
- // 获取有效的 Access Token
- String accessToken = tokenManager.getValidAccessToken(storeId);
-
- // 构建请求
- HttpHeaders headers = new HttpHeaders();
- headers.set("access-token", accessToken);
- headers.setContentType(MediaType.APPLICATION_JSON);
-
- HttpEntity> entity = new HttpEntity<>(headers);
-
- // 发送请求
- ResponseEntity response = restTemplate.exchange(
- endpoint,
- HttpMethod.GET,
- entity,
- responseType
- );
-
- return response.getBody();
-
- } catch (HttpClientErrorException e) {
- if (e.getStatusCode() == HttpStatus.UNAUTHORIZED) {
- // Token 过期,刷新后重试
- tokenManager.forceRefreshToken(storeId);
- retries++;
- continue;
- } else if (e.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) {
- // 触发速率限制,等待后重试
- sleep(RETRY_DELAY_MS * (retries + 1));
- retries++;
- continue;
- } else {
- throw new BusinessException("API call failed: " + e.getMessage());
- }
- } catch (Exception e) {
- lastException = e;
- retries++;
- sleep(RETRY_DELAY_MS);
- }
- }
-
- throw new BusinessException("API call failed after retries", lastException);
- }
-
- private void sleep(long millis) {
- try {
- Thread.sleep(millis);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
-}
-```
-
-### 5.6 速率限制处理
-
-店匠 API 有速率限制(Rate Limit),需要遵守以下规则:
-
-**限制说明:**
-- 每个店铺每秒最多 10 个请求
-- 响应头中包含速率限制信息
-
-**响应头示例:**
-```
-X-RateLimit-Limit: 10
-X-RateLimit-Remaining: 8
-X-RateLimit-Reset: 1699800060
-```
-
-**处理策略:**
-1. 解析响应头中的速率限制信息
-2. 如果 `X-RateLimit-Remaining` 为 0,等待到 `X-RateLimit-Reset` 时间
-3. 收到 429 错误时,使用指数退避重试
-
----
-
-## 6. 数据同步实现
-
-### 6.1 商品数据同步
-
-#### 6.1.1 API 调用
-
-**获取商品列表:**
-
-```bash
-curl --request GET \
- --url 'https://47167113-1.myshoplaza.com/openapi/2022-01/products?page=1&limit=50' \
- --header 'access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY' \
- --header 'accept: application/json'
-```
-
-**响应示例:**
-
-```json
-{
- "products": [
- {
- "id": "193817395",
- "title": "蓝牙耳机",
- "body_html": "高品质蓝牙耳机
",
- "vendor": "Sony",
- "product_type": "Electronics",
- "handle": "bluetooth-headphone",
- "published_at": "2024-01-15T10:00:00Z",
- "created_at": "2024-01-15T09:00:00Z",
- "updated_at": "2024-01-20T14:30:00Z",
- "status": "active",
- "tags": "electronics, audio, bluetooth",
- "variants": [
- {
- "id": "819403847",
- "product_id": "193817395",
- "title": "Black / Standard",
- "price": "99.99",
- "compare_at_price": "129.99",
- "sku": "BT-HP-001",
- "inventory_quantity": 100,
- "weight": "0.25",
- "weight_unit": "kg",
- "requires_shipping": true,
- "option1": "Black",
- "option2": "Standard",
- "option3": null
- }
- ],
- "images": [
- {
- "id": "638746512",
- "product_id": "193817395",
- "src": "https://cdn.shoplazza.com/image1.jpg",
- "position": 1,
- "width": 800,
- "height": 800
- }
- ],
- "options": [
- {
- "id": "123456",
- "name": "Color",
- "values": ["Black", "White", "Blue"]
- },
- {
- "id": "123457",
- "name": "Size",
- "values": ["Standard"]
- }
- ]
- }
- ]
-}
-```
-
-#### 6.1.2 数据表设计
-
-**SPU 表(shoplazza_product_spu):**
-
-```sql
-CREATE TABLE `shoplazza_product_spu` (
- `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
- `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
- `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
- `product_id` VARCHAR(64) NOT NULL COMMENT '店匠商品ID',
- `title` VARCHAR(512) NOT NULL COMMENT '商品标题',
- `body_html` TEXT COMMENT '商品描述HTML',
- `vendor` VARCHAR(255) DEFAULT NULL COMMENT '供应商/品牌',
- `product_type` VARCHAR(255) DEFAULT NULL COMMENT '商品类型',
- `handle` VARCHAR(255) DEFAULT NULL COMMENT '商品URL handle',
- `tags` VARCHAR(1024) DEFAULT NULL COMMENT '标签(逗号分隔)',
- `status` VARCHAR(32) DEFAULT 'active' COMMENT '状态:active, draft, archived',
- `published_at` DATETIME DEFAULT NULL COMMENT '发布时间',
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_store_product` (`store_id`, `product_id`),
- KEY `idx_tenant_id` (`tenant_id`),
- KEY `idx_product_type` (`product_type`),
- KEY `idx_vendor` (`vendor`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠商品SPU表';
-```
-
-**SKU 表(shoplazza_product_sku):**
-
-```sql
-CREATE TABLE `shoplazza_product_sku` (
- `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
- `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
- `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
- `product_id` VARCHAR(64) NOT NULL COMMENT '店匠商品ID',
- `variant_id` VARCHAR(64) NOT NULL COMMENT '店匠变体ID',
- `sku` VARCHAR(255) DEFAULT NULL COMMENT 'SKU编码',
- `title` VARCHAR(512) NOT NULL COMMENT '变体标题',
- `price` DECIMAL(12,2) NOT NULL COMMENT '价格',
- `compare_at_price` DECIMAL(12,2) DEFAULT NULL COMMENT '对比价格',
- `inventory_quantity` INT DEFAULT 0 COMMENT '库存数量',
- `weight` DECIMAL(10,3) DEFAULT NULL COMMENT '重量',
- `weight_unit` VARCHAR(16) DEFAULT NULL COMMENT '重量单位',
- `option1` VARCHAR(255) DEFAULT NULL COMMENT '选项1值',
- `option2` VARCHAR(255) DEFAULT NULL COMMENT '选项2值',
- `option3` VARCHAR(255) DEFAULT NULL COMMENT '选项3值',
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_store_variant` (`store_id`, `variant_id`),
- KEY `idx_tenant_id` (`tenant_id`),
- KEY `idx_product_id` (`product_id`),
- KEY `idx_sku` (`sku`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠商品SKU表';
-```
-
-**图片表(shoplazza_product_image):**
-
-```sql
-CREATE TABLE `shoplazza_product_image` (
- `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
- `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
- `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
- `product_id` VARCHAR(64) NOT NULL COMMENT '店匠商品ID',
- `image_id` VARCHAR(64) NOT NULL COMMENT '店匠图片ID',
- `src` VARCHAR(1024) NOT NULL COMMENT '图片URL',
- `position` INT DEFAULT 1 COMMENT '排序位置',
- `width` INT DEFAULT NULL COMMENT '图片宽度',
- `height` INT DEFAULT NULL COMMENT '图片高度',
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_store_image` (`store_id`, `image_id`),
- KEY `idx_tenant_id` (`tenant_id`),
- KEY `idx_product_id` (`product_id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠商品图片表';
-```
-
-#### 6.1.3 同步逻辑实现
-
-```java
-@Service
-public class ProductSyncService {
-
- @Autowired
- private ShoplazzaApiClient apiClient;
-
- @Autowired
- private ProductSpuMapper spuMapper;
-
- @Autowired
- private ProductSkuMapper skuMapper;
-
- @Autowired
- private ProductImageMapper imageMapper;
-
- /**
- * 同步单个店铺的所有商品
- */
- public void syncProducts(Long shopConfigId) {
- ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
- if (shop == null) {
- throw new BusinessException("Shop not found");
- }
-
- int page = 1;
- int limit = 50;
- boolean hasMore = true;
- int totalSynced = 0;
-
- while (hasMore) {
- try {
- // 调用 API 获取商品列表
- String endpoint = String.format(
- "https://%s.myshoplaza.com/openapi/2022-01/products?page=%d&limit=%d",
- shop.getStoreDomain().split("\\.")[0],
- page,
- limit
- );
-
- ProductListResponse response = apiClient.callApi(
- shop.getStoreId(),
- endpoint,
- ProductListResponse.class
- );
-
- if (response.getProducts() == null || response.getProducts().isEmpty()) {
- hasMore = false;
- break;
- }
-
- // 保存商品数据
- for (ProductDto product : response.getProducts()) {
- saveProduct(shop.getTenantId(), shop.getStoreId(), product);
- totalSynced++;
- }
-
- log.info("Synced page {} for shop {}, total: {}", page, shop.getStoreName(), totalSynced);
-
- // 下一页
- page++;
-
- // 避免触发速率限制
- Thread.sleep(100);
-
- } catch (Exception e) {
- log.error("Failed to sync products for shop: {}", shop.getStoreName(), e);
- throw new BusinessException("Product sync failed", e);
- }
- }
-
- // 更新最后同步时间
- shop.setLastSyncTime(new Date());
- shopConfigMapper.updateById(shop);
-
- log.info("Product sync completed for shop: {}, total synced: {}", shop.getStoreName(), totalSynced);
- }
-
- /**
- * 保存单个商品及其SKU和图片
- */
- @Transactional
- private void saveProduct(Long tenantId, String storeId, ProductDto product) {
- // 1. 保存 SPU
- ProductSpu spu = spuMapper.selectByStoreAndProductId(storeId, product.getId());
- if (spu == null) {
- spu = new ProductSpu();
- spu.setTenantId(tenantId);
- spu.setStoreId(storeId);
- spu.setProductId(product.getId());
- }
-
- spu.setTitle(product.getTitle());
- spu.setBodyHtml(product.getBodyHtml());
- spu.setVendor(product.getVendor());
- spu.setProductType(product.getProductType());
- spu.setHandle(product.getHandle());
- spu.setTags(product.getTags());
- spu.setStatus(product.getStatus());
- spu.setPublishedAt(product.getPublishedAt());
-
- if (spu.getId() == null) {
- spuMapper.insert(spu);
- } else {
- spuMapper.updateById(spu);
- }
-
- // 2. 保存 SKU
- if (product.getVariants() != null) {
- for (VariantDto variant : product.getVariants()) {
- ProductSku sku = skuMapper.selectByStoreAndVariantId(storeId, variant.getId());
- if (sku == null) {
- sku = new ProductSku();
- sku.setTenantId(tenantId);
- sku.setStoreId(storeId);
- sku.setProductId(product.getId());
- sku.setVariantId(variant.getId());
- }
-
- sku.setSku(variant.getSku());
- sku.setTitle(variant.getTitle());
- sku.setPrice(new BigDecimal(variant.getPrice()));
- sku.setCompareAtPrice(variant.getCompareAtPrice() != null ?
- new BigDecimal(variant.getCompareAtPrice()) : null);
- sku.setInventoryQuantity(variant.getInventoryQuantity());
- sku.setWeight(variant.getWeight());
- sku.setWeightUnit(variant.getWeightUnit());
- sku.setOption1(variant.getOption1());
- sku.setOption2(variant.getOption2());
- sku.setOption3(variant.getOption3());
-
- if (sku.getId() == null) {
- skuMapper.insert(sku);
- } else {
- skuMapper.updateById(sku);
- }
- }
- }
-
- // 3. 保存图片
- if (product.getImages() != null) {
- for (ImageDto image : product.getImages()) {
- ProductImage img = imageMapper.selectByStoreAndImageId(storeId, image.getId());
- if (img == null) {
- img = new ProductImage();
- img.setTenantId(tenantId);
- img.setStoreId(storeId);
- img.setProductId(product.getId());
- img.setImageId(image.getId());
- }
-
- img.setSrc(image.getSrc());
- img.setPosition(image.getPosition());
- img.setWidth(image.getWidth());
- img.setHeight(image.getHeight());
-
- if (img.getId() == null) {
- imageMapper.insert(img);
- } else {
- imageMapper.updateById(img);
- }
- }
- }
- }
-}
-```
-
-### 6.2 客户数据同步
-
-#### 6.2.1 数据表设计
-
-**客户表(shoplazza_customer):**
-
-```sql
-CREATE TABLE `shoplazza_customer` (
- `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
- `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
- `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
- `customer_id` VARCHAR(64) NOT NULL COMMENT '店匠客户ID',
- `email` VARCHAR(255) DEFAULT NULL COMMENT '邮箱',
- `phone` VARCHAR(64) DEFAULT NULL COMMENT '电话',
- `first_name` VARCHAR(128) DEFAULT NULL COMMENT '名',
- `last_name` VARCHAR(128) DEFAULT NULL COMMENT '姓',
- `orders_count` INT DEFAULT 0 COMMENT '订单数量',
- `total_spent` DECIMAL(12,2) DEFAULT 0.00 COMMENT '累计消费',
- `state` VARCHAR(32) DEFAULT NULL COMMENT '状态',
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_store_customer` (`store_id`, `customer_id`),
- KEY `idx_tenant_id` (`tenant_id`),
- KEY `idx_email` (`email`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠客户表';
-```
-
-**客户地址表(shoplazza_customer_address):**
-
-```sql
-CREATE TABLE `shoplazza_customer_address` (
- `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
- `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
- `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
- `customer_id` VARCHAR(64) NOT NULL COMMENT '店匠客户ID',
- `address_id` VARCHAR(64) NOT NULL COMMENT '店匠地址ID',
- `first_name` VARCHAR(128) DEFAULT NULL COMMENT '名',
- `last_name` VARCHAR(128) DEFAULT NULL COMMENT '姓',
- `address1` VARCHAR(512) DEFAULT NULL COMMENT '地址行1',
- `address2` VARCHAR(512) DEFAULT NULL COMMENT '地址行2',
- `city` VARCHAR(128) DEFAULT NULL COMMENT '城市',
- `province` VARCHAR(128) DEFAULT NULL COMMENT '省份',
- `country` VARCHAR(128) DEFAULT NULL COMMENT '国家',
- `zip` VARCHAR(32) DEFAULT NULL COMMENT '邮编',
- `phone` VARCHAR(64) DEFAULT NULL COMMENT '电话',
- `is_default` BIT(1) DEFAULT b'0' COMMENT '是否默认地址',
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_store_address` (`store_id`, `address_id`),
- KEY `idx_tenant_id` (`tenant_id`),
- KEY `idx_customer_id` (`customer_id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠客户地址表';
-```
-
-#### 6.2.2 API 调用示例
-
-```bash
-curl --request GET \
- --url 'https://47167113-1.myshoplaza.com/openapi/2022-01/customers?page=1&limit=50' \
- --header 'access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY' \
- --header 'accept: application/json'
-```
-
-### 6.3 订单数据同步
-
-#### 6.3.1 数据表设计
-
-**订单表(shoplazza_order):**
-
-```sql
-CREATE TABLE `shoplazza_order` (
- `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
- `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
- `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
- `order_id` VARCHAR(64) NOT NULL COMMENT '店匠订单ID',
- `order_number` VARCHAR(128) NOT NULL COMMENT '订单号',
- `customer_id` VARCHAR(64) DEFAULT NULL COMMENT '客户ID',
- `email` VARCHAR(255) DEFAULT NULL COMMENT '客户邮箱',
- `total_price` DECIMAL(12,2) NOT NULL COMMENT '订单总价',
- `subtotal_price` DECIMAL(12,2) DEFAULT NULL COMMENT '小计',
- `total_tax` DECIMAL(12,2) DEFAULT NULL COMMENT '税费',
- `total_shipping` DECIMAL(12,2) DEFAULT NULL COMMENT '运费',
- `currency` VARCHAR(16) DEFAULT 'USD' COMMENT '货币',
- `financial_status` VARCHAR(32) DEFAULT NULL COMMENT '支付状态',
- `fulfillment_status` VARCHAR(32) DEFAULT NULL COMMENT '配送状态',
- `order_status` VARCHAR(32) DEFAULT NULL COMMENT '订单状态',
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_store_order` (`store_id`, `order_id`),
- KEY `idx_tenant_id` (`tenant_id`),
- KEY `idx_customer_id` (`customer_id`),
- KEY `idx_order_number` (`order_number`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠订单表';
-```
-
-**订单明细表(shoplazza_order_item):**
-
-```sql
-CREATE TABLE `shoplazza_order_item` (
- `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
- `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
- `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
- `order_id` VARCHAR(64) NOT NULL COMMENT '店匠订单ID',
- `line_item_id` VARCHAR(64) NOT NULL COMMENT '店匠明细ID',
- `product_id` VARCHAR(64) DEFAULT NULL COMMENT '商品ID',
- `variant_id` VARCHAR(64) DEFAULT NULL COMMENT '变体ID',
- `sku` VARCHAR(255) DEFAULT NULL COMMENT 'SKU',
- `title` VARCHAR(512) DEFAULT NULL COMMENT '商品标题',
- `quantity` INT NOT NULL COMMENT '数量',
- `price` DECIMAL(12,2) NOT NULL COMMENT '单价',
- `total_price` DECIMAL(12,2) NOT NULL COMMENT '总价',
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_store_line_item` (`store_id`, `line_item_id`),
- KEY `idx_tenant_id` (`tenant_id`),
- KEY `idx_order_id` (`order_id`),
- KEY `idx_product_id` (`product_id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠订单明细表';
-```
-
-### 6.4 同步调度策略
-
-#### 6.4.1 首次全量同步
-
-商家安装 APP 后,触发首次全量同步:
-
-```java
-@Service
-public class DataSyncService {
-
- @Async
- public void syncAllData(Long shopConfigId) {
- log.info("Starting full data sync for shop: {}", shopConfigId);
-
- try {
- // 1. 同步商品(优先级最高)
- productSyncService.syncProducts(shopConfigId);
-
- // 2. 同步客户
- customerSyncService.syncCustomers(shopConfigId);
-
- // 3. 同步订单
- orderSyncService.syncOrders(shopConfigId);
-
- // 4. 注册 Webhook
- webhookService.registerWebhooks(shopConfigId);
-
- // 5. 索引商品到 ES
- esIndexService.indexProducts(shopConfigId);
-
- log.info("Full data sync completed for shop: {}", shopConfigId);
-
- } catch (Exception e) {
- log.error("Full data sync failed for shop: {}", shopConfigId, e);
- // 可选:发送告警通知
- }
- }
-}
-```
-
-#### 6.4.2 定时增量同步
-
-配置定时任务,定期同步数据:
-
-```java
-@Component
-public class ScheduledSyncTask {
-
- @Autowired
- private DataSyncService dataSyncService;
-
- @Autowired
- private ShopConfigMapper shopConfigMapper;
-
- /**
- * 每小时同步一次商品数据
- */
- @Scheduled(cron = "0 0 * * * ?")
- public void syncProductsHourly() {
- List activeShops = shopConfigMapper.selectActiveShops();
-
- for (ShopConfig shop : activeShops) {
- try {
- productSyncService.syncProducts(shop.getId());
- } catch (Exception e) {
- log.error("Scheduled product sync failed for shop: {}", shop.getStoreName(), e);
- }
- }
- }
-
- /**
- * 每天同步一次客户和订单数据
- */
- @Scheduled(cron = "0 0 3 * * ?")
- public void syncCustomersAndOrdersDaily() {
- List activeShops = shopConfigMapper.selectActiveShops();
-
- for (ShopConfig shop : activeShops) {
- try {
- customerSyncService.syncCustomers(shop.getId());
- orderSyncService.syncOrders(shop.getId());
- } catch (Exception e) {
- log.error("Scheduled sync failed for shop: {}", shop.getStoreName(), e);
- }
- }
- }
-}
-```
-
-#### 6.4.3 失败重试机制
-
-使用 Spring Retry 实现失败重试:
-
-```java
-@Service
-public class RobustSyncService {
-
- @Retryable(
- value = {ApiException.class, HttpClientErrorException.class},
- maxAttempts = 3,
- backoff = @Backoff(delay = 2000, multiplier = 2)
- )
- public void syncWithRetry(Long shopConfigId, String syncType) {
- switch (syncType) {
- case "products":
- productSyncService.syncProducts(shopConfigId);
- break;
- case "customers":
- customerSyncService.syncCustomers(shopConfigId);
- break;
- case "orders":
- orderSyncService.syncOrders(shopConfigId);
- break;
- default:
- throw new IllegalArgumentException("Unknown sync type: " + syncType);
- }
- }
-
- @Recover
- public void recoverFromSyncFailure(Exception e, Long shopConfigId, String syncType) {
- log.error("Sync failed after retries: shop={}, type={}", shopConfigId, syncType, e);
- // 记录失败日志,发送告警
- alertService.sendAlert("Data sync failed",
- String.format("Shop: %d, Type: %s, Error: %s", shopConfigId, syncType, e.getMessage()));
- }
-}
-```
-
----
-
-## 7. Webhook 集成
-
-### 7.1 Webhook 概述
-
-Webhook 是店匠平台的事件通知机制,当店铺发生特定事件(如商品更新、订单创建)时,店匠会主动向你注册的 Webhook 地址发送 HTTP POST 请求,实现实时数据同步。
-
-**优势:**
-- ✅ 实时性:事件发生后立即通知
-- ✅ 减少 API 调用:避免频繁轮询
-- ✅ 精准更新:只更新变化的数据
-
-### 7.2 支持的 Webhook Topic
-
-店匠支持以下 Webhook 事件类型:
-
-#### 7.2.1 商品相关
-
-| Topic | 说明 | 触发时机 |
-|-------|------|----------|
-| `products/create` | 商品创建 | 商家创建新商品时 |
-| `products/update` | 商品更新 | 商家修改商品信息时 |
-| `products/delete` | 商品删除 | 商家删除商品时 |
-
-#### 7.2.2 订单相关
-
-| Topic | 说明 | 触发时机 |
-|-------|------|----------|
-| `orders/create` | 订单创建 | 买家下单时 |
-| `orders/updated` | 订单更新 | 订单状态变化时 |
-| `orders/paid` | 订单支付 | 订单支付成功时 |
-| `orders/cancelled` | 订单取消 | 订单被取消时 |
-
-#### 7.2.3 客户相关
-
-| Topic | 说明 | 触发时机 |
-|-------|------|----------|
-| `customers/create` | 客户创建 | 新客户注册时 |
-| `customers/update` | 客户更新 | 客户信息更新时 |
-| `customers/delete` | 客户删除 | 客户被删除时 |
-
-### 7.3 注册 Webhook
-
-#### 7.3.1 API 调用
-
-店铺激活后,自动注册所需的 Webhook:
-
-```bash
-curl --request POST \
- --url 'https://47167113-1.myshoplaza.com/openapi/2022-01/webhooks' \
- --header 'access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY' \
- --header 'accept: application/json' \
- --header 'content-type: application/json' \
- --data '{
- "address": "https://your-domain.com/webhook/shoplazza",
- "topic": "products/update"
- }'
-```
-
-**响应示例:**
-
-```json
-{
- "webhook": {
- "id": "123456",
- "address": "https://your-domain.com/webhook/shoplazza",
- "topic": "products/update",
- "created_at": "2024-01-15T10:00:00Z",
- "updated_at": "2024-01-15T10:00:00Z"
- }
-}
-```
-
-#### 7.3.2 批量注册实现
-
-```java
-@Service
-public class WebhookService {
-
- private static final List WEBHOOK_TOPICS = Arrays.asList(
- "products/create",
- "products/update",
- "products/delete",
- "orders/create",
- "orders/updated",
- "orders/paid",
- "customers/create",
- "customers/update"
- );
-
- /**
- * 为店铺注册所有 Webhook
- */
- public void registerWebhooks(Long shopConfigId) {
- ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
- if (shop == null) {
- throw new BusinessException("Shop not found");
- }
-
- String webhookUrl = buildWebhookUrl(shop.getStoreId());
-
- for (String topic : WEBHOOK_TOPICS) {
- try {
- registerSingleWebhook(shop, webhookUrl, topic);
- log.info("Registered webhook for shop: {}, topic: {}", shop.getStoreName(), topic);
- } catch (Exception e) {
- log.error("Failed to register webhook: shop={}, topic={}", shop.getStoreName(), topic, e);
- // 继续注册其他 Webhook
- }
- }
- }
-
- private void registerSingleWebhook(ShopConfig shop, String webhookUrl, String topic) {
- String endpoint = String.format(
- "https://%s/openapi/2022-01/webhooks",
- shop.getStoreDomain()
- );
-
- WebhookRequest request = new WebhookRequest();
- request.setAddress(webhookUrl);
- request.setTopic(topic);
-
- apiClient.post(shop.getStoreId(), endpoint, request, WebhookResponse.class);
- }
-
- private String buildWebhookUrl(String storeId) {
- return String.format("%s/webhook/shoplazza/%s",
- appConfig.getBaseUrl(),
- storeId);
- }
-}
-```
-
-### 7.4 接收和处理 Webhook
-
-#### 7.4.1 Webhook 请求格式
-
-店匠发送的 Webhook 请求格式:
-
-```http
-POST /webhook/shoplazza/{store_id}
-Content-Type: application/json
-X-Shoplazza-Hmac-Sha256: {signature}
-X-Shoplazza-Topic: products/update
-X-Shoplazza-Shop-Domain: 47167113-1.myshoplaza.com
-
-{
- "id": "193817395",
- "title": "蓝牙耳机",
- "variants": [...],
- "images": [...],
- ...
-}
-```
-
-**请求头说明:**
-- `X-Shoplazza-Hmac-Sha256`:HMAC-SHA256 签名(用于验证请求真实性)
-- `X-Shoplazza-Topic`:事件类型
-- `X-Shoplazza-Shop-Domain`:店铺域名
-
-#### 7.4.2 签名验证
-
-为了确保 Webhook 请求来自店匠平台,需要验证签名:
-
-```java
-@RestController
-@RequestMapping("/webhook/shoplazza")
-public class WebhookController {
-
- @Autowired
- private WebhookService webhookService;
-
- @PostMapping("/{storeId}")
- public ResponseEntity handleWebhook(
- @PathVariable String storeId,
- @RequestHeader("X-Shoplazza-Hmac-Sha256") String signature,
- @RequestHeader("X-Shoplazza-Topic") String topic,
- @RequestHeader("X-Shoplazza-Shop-Domain") String shopDomain,
- @RequestBody String payload) {
-
- try {
- // 1. 验证签名
- if (!webhookService.verifySignature(storeId, payload, signature)) {
- log.warn("Invalid webhook signature: store={}, topic={}", storeId, topic);
- return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid signature");
- }
-
- // 2. 处理事件(异步)
- webhookService.processWebhookAsync(storeId, topic, payload);
-
- // 3. 立即返回 200(店匠要求3秒内响应)
- return ResponseEntity.ok("OK");
-
- } catch (Exception e) {
- log.error("Failed to handle webhook: store={}, topic={}", storeId, topic, e);
- return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error");
- }
- }
-}
-
-@Service
-public class WebhookService {
-
- /**
- * 验证 Webhook 签名
- */
- public boolean verifySignature(String storeId, String payload, String signature) {
- ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
- if (shop == null) {
- return false;
- }
-
- // 使用 Client Secret 作为签名密钥
- String clientSecret = appConfig.getClientSecret();
-
- try {
- Mac mac = Mac.getInstance("HmacSHA256");
- SecretKeySpec secretKey = new SecretKeySpec(
- clientSecret.getBytes(StandardCharsets.UTF_8),
- "HmacSHA256"
- );
- mac.init(secretKey);
-
- byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
- String computedSignature = Base64.getEncoder().encodeToString(hash);
-
- return computedSignature.equals(signature);
-
- } catch (Exception e) {
- log.error("Failed to verify signature", e);
- return false;
- }
- }
-
- /**
- * 异步处理 Webhook 事件
- */
- @Async
- public void processWebhookAsync(String storeId, String topic, String payload) {
- try {
- log.info("Processing webhook: store={}, topic={}", storeId, topic);
-
- switch (topic) {
- case "products/create":
- case "products/update":
- handleProductUpdate(storeId, payload);
- break;
- case "products/delete":
- handleProductDelete(storeId, payload);
- break;
- case "orders/create":
- case "orders/updated":
- case "orders/paid":
- handleOrderUpdate(storeId, payload);
- break;
- case "orders/cancelled":
- handleOrderCancel(storeId, payload);
- break;
- case "customers/create":
- case "customers/update":
- handleCustomerUpdate(storeId, payload);
- break;
- case "customers/delete":
- handleCustomerDelete(storeId, payload);
- break;
- default:
- log.warn("Unknown webhook topic: {}", topic);
- }
-
- } catch (Exception e) {
- log.error("Failed to process webhook: store={}, topic={}", storeId, topic, e);
- }
- }
-
- private void handleProductUpdate(String storeId, String payload) {
- ProductDto product = JSON.parseObject(payload, ProductDto.class);
- ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
-
- // 更新数据库
- productSyncService.saveProduct(shop.getTenantId(), storeId, product);
-
- // 更新 ES 索引
- esIndexService.indexSingleProduct(shop.getTenantId(), product.getId());
- }
-
- private void handleProductDelete(String storeId, String payload) {
- ProductDto product = JSON.parseObject(payload, ProductDto.class);
- ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
-
- // 软删除数据库记录
- productSpuMapper.softDeleteByProductId(storeId, product.getId());
-
- // 从 ES 中删除
- esIndexService.deleteProduct(shop.getTenantId(), product.getId());
- }
-
- // ... 其他事件处理方法
-}
-```
-
-### 7.5 幂等性保证
-
-为了避免重复处理同一个事件,需要实现幂等性:
-
-```java
-@Service
-public class WebhookEventService {
-
- @Autowired
- private RedisTemplate redisTemplate;
-
- /**
- * 检查事件是否已处理(使用 Redis 去重)
- */
- public boolean isEventProcessed(String storeId, String topic, String eventId) {
- String key = String.format("webhook:processed:%s:%s:%s", storeId, topic, eventId);
- return Boolean.TRUE.equals(redisTemplate.hasKey(key));
- }
-
- /**
- * 标记事件已处理(保留24小时)
- */
- public void markEventProcessed(String storeId, String topic, String eventId) {
- String key = String.format("webhook:processed:%s:%s:%s", storeId, topic, eventId);
- redisTemplate.opsForValue().set(key, "1", 24, TimeUnit.HOURS);
- }
-
- /**
- * 处理事件(带幂等性保证)
- */
- @Transactional
- public void processEventIdempotent(String storeId, String topic, String eventId, Runnable handler) {
- // 检查是否已处理
- if (isEventProcessed(storeId, topic, eventId)) {
- log.info("Event already processed: store={}, topic={}, eventId={}", storeId, topic, eventId);
- return;
- }
-
- // 处理事件
- handler.run();
-
- // 标记已处理
- markEventProcessed(storeId, topic, eventId);
- }
-}
-```
-
----
-
-## 8. Elasticsearch 索引
-
-### 8.1 索引结构设计
-
-基于店匠商品结构,设计 Elasticsearch mapping:
-
-```json
-{
- "settings": {
- "number_of_shards": 3,
- "number_of_replicas": 1,
- "analysis": {
- "analyzer": {
- "chinese_ecommerce": {
- "type": "custom",
- "tokenizer": "ik_max_word",
- "filter": ["lowercase"]
- }
- }
- }
- },
- "mappings": {
- "properties": {
- "tenant_id": {
- "type": "keyword"
- },
- "store_id": {
- "type": "keyword"
- },
- "product_id": {
- "type": "keyword"
- },
- "title": {
- "type": "text",
- "analyzer": "chinese_ecommerce",
- "fields": {
- "keyword": {
- "type": "keyword"
- },
- "en": {
- "type": "text",
- "analyzer": "english"
- }
- }
- },
- "title_embedding": {
- "type": "dense_vector",
- "dims": 1024,
- "index": true,
- "similarity": "cosine"
- },
- "body_html": {
- "type": "text",
- "analyzer": "chinese_ecommerce"
- },
- "vendor": {
- "type": "keyword"
- },
- "product_type": {
- "type": "keyword"
- },
- "tags": {
- "type": "keyword"
- },
- "price": {
- "type": "float"
- },
- "compare_at_price": {
- "type": "float"
- },
- "inventory_quantity": {
- "type": "integer"
- },
- "image_url": {
- "type": "keyword",
- "index": false
- },
- "image_embedding": {
- "type": "dense_vector",
- "dims": 1024,
- "index": true,
- "similarity": "cosine"
- },
- "variants": {
- "type": "nested",
- "properties": {
- "variant_id": {"type": "keyword"},
- "sku": {"type": "keyword"},
- "title": {"type": "text", "analyzer": "chinese_ecommerce"},
- "price": {"type": "float"},
- "inventory_quantity": {"type": "integer"},
- "option1": {"type": "keyword"},
- "option2": {"type": "keyword"},
- "option3": {"type": "keyword"}
- }
- },
- "status": {
- "type": "keyword"
- },
- "created_at": {
- "type": "date"
- },
- "updated_at": {
- "type": "date"
- }
- }
- }
-}
-```
-
-### 8.2 索引命名规范
-
-使用租户隔离的索引命名:
-
-```
-shoplazza_products_{tenant_id}
-```
-
-例如:
-- `shoplazza_products_1`
-- `shoplazza_products_2`
-
-### 8.3 数据索引流程
-
-#### 8.3.1 从数据库读取商品
-
-```java
-@Service
-public class EsIndexService {
-
- @Autowired
- private ProductSpuMapper spuMapper;
-
- @Autowired
- private ProductSkuMapper skuMapper;
-
- @Autowired
- private ProductImageMapper imageMapper;
-
- @Autowired
- private EmbeddingService embeddingService;
-
- @Autowired
- private RestHighLevelClient esClient;
-
- /**
- * 为店铺的所有商品建立索引
- */
- public void indexProducts(Long shopConfigId) {
- ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
- if (shop == null) {
- throw new BusinessException("Shop not found");
- }
-
- String indexName = String.format("shoplazza_products_%d", shop.getTenantId());
-
- // 1. 创建索引(如果不存在)
- createIndexIfNotExists(indexName);
-
- // 2. 查询所有商品
- List products = spuMapper.selectByStoreId(shop.getStoreId());
-
- // 3. 批量索引
- BulkRequest bulkRequest = new BulkRequest();
-
- for (ProductSpu spu : products) {
- try {
- // 构建 ES 文档
- Map doc = buildEsDocument(shop.getTenantId(), spu);
-
- // 添加到批量请求
- IndexRequest indexRequest = new IndexRequest(indexName)
- .id(spu.getProductId())
- .source(doc);
- bulkRequest.add(indexRequest);
-
- // 每500条提交一次
- if (bulkRequest.numberOfActions() >= 500) {
- BulkResponse bulkResponse = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
- if (bulkResponse.hasFailures()) {
- log.error("Bulk index has failures: {}", bulkResponse.buildFailureMessage());
- }
- bulkRequest = new BulkRequest();
- }
-
- } catch (Exception e) {
- log.error("Failed to index product: {}", spu.getProductId(), e);
- }
- }
-
- // 4. 提交剩余的文档
- if (bulkRequest.numberOfActions() > 0) {
- BulkResponse bulkResponse = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
- if (bulkResponse.hasFailures()) {
- log.error("Bulk index has failures: {}", bulkResponse.buildFailureMessage());
- }
- }
-
- log.info("Indexed {} products for shop: {}", products.size(), shop.getStoreName());
- }
-
- /**
- * 构建 ES 文档
- */
- private Map buildEsDocument(Long tenantId, ProductSpu spu) {
- Map doc = new HashMap<>();
-
- // 基本字段
- doc.put("tenant_id", tenantId.toString());
- doc.put("store_id", spu.getStoreId());
- doc.put("product_id", spu.getProductId());
- doc.put("title", spu.getTitle());
- doc.put("body_html", spu.getBodyHtml());
- doc.put("vendor", spu.getVendor());
- doc.put("product_type", spu.getProductType());
- doc.put("status", spu.getStatus());
- doc.put("created_at", spu.getCreatedAt());
- doc.put("updated_at", spu.getUpdatedAt());
-
- // 标签
- if (StringUtils.isNotEmpty(spu.getTags())) {
- doc.put("tags", Arrays.asList(spu.getTags().split(",")));
- }
-
- // 变体(SKU)
- List skus = skuMapper.selectByProductId(spu.getProductId());
- if (CollectionUtils.isNotEmpty(skus)) {
- List