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}}

- -
-

下一步操作:

-
    -
  1. 进入店铺后台 → 主题装修
  2. -
  3. 点击"添加卡片" → 选择"APPS" → 找到"搜索 SaaS"
  4. -
  5. 拖拽搜索组件到页面中
  6. -
  7. 保存并发布主题
  8. -
-
- - 前往店铺后台 - - -``` - ---- - -## 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> variants = new ArrayList<>(); - for (ProductSku sku : skus) { - Map variant = new HashMap<>(); - variant.put("variant_id", sku.getVariantId()); - variant.put("sku", sku.getSku()); - variant.put("title", sku.getTitle()); - variant.put("price", sku.getPrice()); - variant.put("inventory_quantity", sku.getInventoryQuantity()); - variant.put("option1", sku.getOption1()); - variant.put("option2", sku.getOption2()); - variant.put("option3", sku.getOption3()); - variants.add(variant); - } - doc.put("variants", variants); - - // 使用第一个 SKU 的价格和库存 - ProductSku firstSku = skus.get(0); - doc.put("price", firstSku.getPrice()); - doc.put("inventory_quantity", firstSku.getInventoryQuantity()); - } - - // 图片 - List images = imageMapper.selectByProductId(spu.getProductId()); - if (CollectionUtils.isNotEmpty(images)) { - ProductImage firstImage = images.get(0); - doc.put("image_url", firstImage.getSrc()); - - // 生成图片向量 - try { - float[] imageEmbedding = embeddingService.encodeImage(firstImage.getSrc()); - doc.put("image_embedding", imageEmbedding); - } catch (Exception e) { - log.warn("Failed to encode image: {}", firstImage.getSrc(), e); - } - } - - // 生成标题向量 - try { - float[] titleEmbedding = embeddingService.encodeText(spu.getTitle()); - doc.put("title_embedding", titleEmbedding); - } catch (Exception e) { - log.warn("Failed to encode title: {}", spu.getTitle(), e); - } - - return doc; - } -} -``` - -#### 8.3.2 调用 Python 向量服务 - -向量生成需要调用 Python 服务: - -```java -@Service -public class EmbeddingService { - - @Autowired - private RestTemplate restTemplate; - - @Value("${embedding.service.url}") - private String embeddingServiceUrl; - - /** - * 生成文本向量 - */ - public float[] encodeText(String text) { - try { - String url = embeddingServiceUrl + "/encode/text"; - - Map request = new HashMap<>(); - request.put("text", text); - - ResponseEntity response = restTemplate.postForEntity( - url, - request, - EmbeddingResponse.class - ); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - return response.getBody().getEmbedding(); - } - - throw new BusinessException("Failed to encode text"); - - } catch (Exception e) { - log.error("Failed to call embedding service", e); - throw new BusinessException("Embedding service error", e); - } - } - - /** - * 生成图片向量 - */ - public float[] encodeImage(String imageUrl) { - try { - String url = embeddingServiceUrl + "/encode/image"; - - Map request = new HashMap<>(); - request.put("image_url", imageUrl); - - ResponseEntity response = restTemplate.postForEntity( - url, - request, - EmbeddingResponse.class - ); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - return response.getBody().getEmbedding(); - } - - throw new BusinessException("Failed to encode image"); - - } catch (Exception e) { - log.error("Failed to call embedding service", e); - throw new BusinessException("Embedding service error", e); - } - } -} -``` - -### 8.4 增量索引更新 - -Webhook 触发增量更新: - -```java -public void indexSingleProduct(Long tenantId, String productId) { - String indexName = String.format("shoplazza_products_%d", tenantId); - - ProductSpu spu = spuMapper.selectByProductId(productId); - if (spu == null) { - log.warn("Product not found: {}", productId); - return; - } - - try { - // 构建文档 - Map doc = buildEsDocument(tenantId, spu); - - // 索引文档 - IndexRequest request = new IndexRequest(indexName) - .id(productId) - .source(doc); - - esClient.index(request, RequestOptions.DEFAULT); - - log.info("Indexed product: {}", productId); - - } catch (Exception e) { - log.error("Failed to index product: {}", productId, e); - } -} - -public void deleteProduct(Long tenantId, String productId) { - String indexName = String.format("shoplazza_products_%d", tenantId); - - try { - DeleteRequest request = new DeleteRequest(indexName, productId); - esClient.delete(request, RequestOptions.DEFAULT); - - log.info("Deleted product from ES: {}", productId); - - } catch (Exception e) { - log.error("Failed to delete product from ES: {}", productId, e); - } -} -``` - ---- - -## 9. 搜索服务集成 - -### 9.1 搜索 API 调用 - -Java 后端接收前端搜索请求后,转发给 Python 搜索服务: - -```java -@RestController -@RequestMapping("/api/search") -public class SearchController { - - @Autowired - private SearchService searchService; - - @PostMapping("/products") - public ResponseEntity searchProducts( - @RequestParam String storeId, - @RequestBody SearchRequest request) { - - try { - // 查询店铺配置,获取 tenant_id - ShopConfig shop = shopConfigMapper.selectByStoreId(storeId); - if (shop == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); - } - - // 调用 Python 搜索服务 - SearchResponse response = searchService.search(shop.getTenantId(), request); - - // 记录搜索日志 - searchLogService.logSearch(shop.getId(), request.getQuery(), response.getTotal()); - - return ResponseEntity.ok(response); - - } catch (Exception e) { - log.error("Search failed: storeId={}, query={}", storeId, request.getQuery(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); - } - } -} - -@Service -public class SearchService { - - @Autowired - private RestTemplate restTemplate; - - @Value("${search.service.url}") - private String searchServiceUrl; - - /** - * 调用 Python 搜索服务 - */ - public SearchResponse search(Long tenantId, SearchRequest request) { - try { - String url = searchServiceUrl + "/search/"; - - // 添加租户隔离参数 - request.setCustomer("tenant_" + tenantId); - - ResponseEntity response = restTemplate.postForEntity( - url, - request, - SearchResponse.class - ); - - if (response.getStatusCode().is2xxSuccessful()) { - return response.getBody(); - } - - throw new BusinessException("Search service returned error: " + response.getStatusCode()); - - } catch (Exception e) { - log.error("Failed to call search service", e); - throw new BusinessException("Search service error", e); - } - } -} -``` - -### 9.2 店铺隔离 - -每个店铺对应一个租户,使用不同的 ES 索引: - -```python -# Python 搜索服务 -@app.post("/search/") -async def search_products(request: SearchRequest): - # 根据 customer 参数确定租户 ID - tenant_id = extract_tenant_id(request.customer) - - # 使用租户专属索引 - index_name = f"shoplazza_products_{tenant_id}" - - # 构建 ES 查询 - es_query = build_es_query(request) - - # 执行搜索 - response = es_client.search( - index=index_name, - body=es_query - ) - - # 返回结果 - return format_search_response(response) -``` - -### 9.3 搜索行为统计 - -#### 9.3.1 日志表设计 - -```sql -CREATE TABLE `shoplazza_search_log` ( - `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) DEFAULT NULL COMMENT '客户ID', - `session_id` VARCHAR(128) DEFAULT NULL COMMENT '会话ID', - `query` VARCHAR(512) NOT NULL COMMENT '搜索关键词', - `results_count` INT DEFAULT 0 COMMENT '结果数量', - `search_type` VARCHAR(32) DEFAULT 'text' COMMENT '搜索类型:text, image, ai', - `language` VARCHAR(16) DEFAULT NULL COMMENT '搜索语言', - `has_results` BIT(1) DEFAULT b'1' COMMENT '是否有结果', - `response_time_ms` INT DEFAULT NULL COMMENT '响应时间(毫秒)', - `ip_address` VARCHAR(64) DEFAULT NULL COMMENT 'IP地址', - `user_agent` VARCHAR(512) DEFAULT NULL COMMENT 'User Agent', - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - PRIMARY KEY (`id`), - KEY `idx_tenant_id` (`tenant_id`), - KEY `idx_store_id` (`store_id`), - KEY `idx_query` (`query`), - KEY `idx_created_at` (`created_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠搜索日志表'; -``` - -#### 9.3.2 日志记录实现 - -```java -@Service -public class SearchLogService { - - @Autowired - private SearchLogMapper searchLogMapper; - - /** - * 记录搜索日志 - */ - @Async - public void logSearch(Long shopConfigId, SearchRequest request, SearchResponse response, - long responseTime, HttpServletRequest httpRequest) { - try { - ShopConfig shop = shopConfigMapper.selectById(shopConfigId); - - SearchLog log = new SearchLog(); - log.setTenantId(shop.getTenantId()); - log.setStoreId(shop.getStoreId()); - log.setCustomerId(request.getCustomerId()); - log.setSessionId(request.getSessionId()); - log.setQuery(request.getQuery()); - log.setResultsCount(response.getTotal()); - log.setSearchType(request.getSearchType()); - log.setLanguage(request.getLanguage()); - log.setHasResults(response.getTotal() > 0); - log.setResponseTimeMs((int) responseTime); - log.setIpAddress(getClientIp(httpRequest)); - log.setUserAgent(httpRequest.getHeader("User-Agent")); - - searchLogMapper.insert(log); - - } catch (Exception e) { - log.error("Failed to log search", e); - } - } - - /** - * 统计分析:热门搜索词 - */ - public List getHotQueries(String storeId, int limit) { - return searchLogMapper.selectHotQueries(storeId, limit); - } - - /** - * 统计分析:无结果搜索 - */ - public List getNoResultQueries(String storeId, int limit) { - return searchLogMapper.selectNoResultQueries(storeId, limit); - } -} -``` - ---- - -## 10. 前端扩展开发 - -### 10.1 主题扩展开发 - -店匠使用 Liquid 模板语言开发主题扩展。 - -#### 10.1.1 创建扩展项目 - -```bash -mkdir shoplazza-ai-search-app -cd shoplazza-ai-search-app - -# 目录结构 -├── app-blocks/ -│ ├── search-box.liquid # 搜索框组件 -│ ├── search-results.liquid # 搜索结果组件 -│ └── settings.json # 组件配置 -├── assets/ -│ ├── search-box.js # JavaScript -│ ├── search-box.css # 样式 -│ └── search-results.js -├── locales/ -│ ├── en.json # 英文翻译 -│ ├── zh-CN.json # 中文翻译 -│ └── es.json # 西班牙语翻译 -└── config.json # APP 配置 -``` - -#### 10.1.2 搜索框组件(search-box.liquid) - -```liquid - - - - - - -``` - -#### 10.1.3 搜索框 JavaScript(search-box.js) - -```javascript -// 搜索框功能 -(function() { - const config = window.AI_SEARCH_CONFIG || {}; - let searchTimeout; - - function handleSearch(event) { - event.preventDefault(); - const query = event.target.q.value.trim(); - - if (!query) return false; - - // 跳转到搜索结果页 - window.location.href = `/pages/search-results?q=${encodeURIComponent(query)}`; - return false; - } - - // 搜索建议(自动补全) - function setupAutocomplete() { - const input = document.querySelector('.search-input'); - const suggestionsContainer = document.getElementById('search-suggestions'); - - if (!input || !suggestionsContainer) return; - - input.addEventListener('input', function(e) { - clearTimeout(searchTimeout); - const query = e.target.value.trim(); - - if (query.length < 2) { - suggestionsContainer.innerHTML = ''; - suggestionsContainer.style.display = 'none'; - return; - } - - searchTimeout = setTimeout(() => { - fetchSuggestions(query); - }, 300); - }); - - // 点击外部关闭建议 - document.addEventListener('click', function(e) { - if (!e.target.closest('.ai-search-box')) { - suggestionsContainer.style.display = 'none'; - } - }); - } - - async function fetchSuggestions(query) { - try { - const response = await fetch(`${config.apiEndpoint}/suggestions?q=${encodeURIComponent(query)}&store_id=${config.storeId}`); - const data = await response.json(); - - if (data.suggestions && data.suggestions.length > 0) { - renderSuggestions(data.suggestions); - } - } catch (error) { - console.error('Failed to fetch suggestions:', error); - } - } - - function renderSuggestions(suggestions) { - const container = document.getElementById('search-suggestions'); - - const html = suggestions.map(item => ` -
- ${item.text} -
- `).join(''); - - container.innerHTML = html; - container.style.display = 'block'; - } - - window.selectSuggestion = function(text) { - document.querySelector('.search-input').value = text; - document.getElementById('search-suggestions').style.display = 'none'; - document.querySelector('.search-form').submit(); - }; - - window.handleSearch = handleSearch; - - // 初始化 - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', setupAutocomplete); - } else { - setupAutocomplete(); - } -})(); -``` - -#### 10.1.4 搜索结果页(search-results.liquid) - -```liquid -
-
-

{{ 'search.title' | t }}

-
- {{ 'search.results_for' | t }}: -
-
- -
- -
- -
-
{{ 'search.loading' | t }}
-
- -
-
- - - - - -``` - -#### 10.1.5 搜索结果 JavaScript(search-results.js) - -```javascript -(function() { - const config = window.AI_SEARCH_CONFIG || {}; - let currentPage = 1; - let currentQuery = ''; - let currentFilters = {}; - - // 从 URL 获取搜索参数 - function getSearchParams() { - const params = new URLSearchParams(window.location.search); - return { - query: params.get('q') || '', - page: parseInt(params.get('page')) || 1 - }; - } - - // 执行搜索 - async function performSearch() { - const params = getSearchParams(); - currentQuery = params.query; - currentPage = params.page; - - if (!currentQuery) { - showError('Please enter a search query'); - return; - } - - document.getElementById('current-query').textContent = currentQuery; - showLoading(); - - try { - const response = await fetch(config.apiEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query: currentQuery, - page: currentPage, - size: 24, - filters: currentFilters, - facets: ['product_type', 'vendor', 'tags'], - customer: `tenant_${config.storeId}` - }) - }); - - const data = await response.json(); - - if (data.results) { - renderResults(data.results); - renderFacets(data.facets); - renderPagination(data.total, currentPage, 24); - } else { - showError('No results found'); - } - } catch (error) { - console.error('Search failed:', error); - showError('Search failed. Please try again.'); - } - } - - // 渲染搜索结果 - function renderResults(results) { - const container = document.getElementById('search-results'); - - if (results.length === 0) { - container.innerHTML = '
No products found
'; - return; - } - - const html = results.map(product => ` - - `).join(''); - - container.innerHTML = html; - } - - // 渲染分面过滤器 - function renderFacets(facets) { - const container = document.getElementById('search-filters'); - - if (!facets || Object.keys(facets).length === 0) { - container.innerHTML = ''; - return; - } - - let html = '
Filters
'; - - for (const [field, values] of Object.entries(facets)) { - if (values.length === 0) continue; - - html += ` -
-

${formatFieldName(field)}

-
- ${values.map(item => ` - - `).join('')} -
-
- `; - } - - container.innerHTML = html; - } - - // 切换过滤器 - window.toggleFilter = function(field, value) { - if (!currentFilters[field]) { - currentFilters[field] = []; - } - - const index = currentFilters[field].indexOf(value); - if (index > -1) { - currentFilters[field].splice(index, 1); - if (currentFilters[field].length === 0) { - delete currentFilters[field]; - } - } else { - currentFilters[field].push(value); - } - - currentPage = 1; - performSearch(); - }; - - // 渲染分页 - function renderPagination(total, page, pageSize) { - const container = document.getElementById('search-pagination'); - const totalPages = Math.ceil(total / pageSize); - - if (totalPages <= 1) { - container.innerHTML = ''; - return; - } - - let html = ''; - container.innerHTML = html; - } - - // 工具函数 - function formatPrice(price, currency) { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency || 'USD' - }).format(price); - } - - function formatFieldName(field) { - return field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - } - - function showLoading() { - document.getElementById('search-results').innerHTML = '
Loading...
'; - } - - function showError(message) { - document.getElementById('search-results').innerHTML = `
${message}
`; - } - - // 初始化 - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', performSearch); - } else { - performSearch(); - } -})(); -``` - -### 10.2 多语言支持 - -#### 10.2.1 中文翻译(locales/zh-CN.json) - -```json -{ - "search": { - "placeholder": "搜索商品...", - "title": "搜索结果", - "results_for": "搜索", - "loading": "加载中...", - "no_results": "未找到相关商品", - "filters": "筛选", - "clear_filters": "清除筛选" - } -} -``` - -#### 10.2.2 英文翻译(locales/en.json) - -```json -{ - "search": { - "placeholder": "Search products...", - "title": "Search Results", - "results_for": "Search results for", - "loading": "Loading...", - "no_results": "No products found", - "filters": "Filters", - "clear_filters": "Clear filters" - } -} -``` - -### 10.3 主题装修集成 - -商家可以在店铺后台的主题装修中添加搜索扩展: - -1. 进入店铺后台 → 主题 → 装修 -2. 点击"添加卡片" -3. 选择"APPS"分类 -4. 找到"AI 搜索" APP -5. 拖拽"搜索框"组件到导航栏或页面顶部 -6. 创建自定义页面"搜索结果",添加"搜索结果"组件 -7. 保存并发布主题 - ---- - -## 11. 部署和上线 - -### 11.1 域名和 SSL 配置 - -#### 11.1.1 域名申请 - -申请一个公网域名,例如: -``` -saas-ai-api.example.com -``` - -#### 11.1.2 SSL 证书配置 - -使用 Let's Encrypt 或其他 CA 颁发的 SSL 证书: - -```bash -# 使用 Certbot 申请证书 -sudo apt-get install certbot -sudo certbot certonly --standalone -d saas-ai-api.example.com -``` - -#### 11.1.3 Nginx 配置 - -```nginx -server { - listen 443 ssl http2; - server_name saas-ai-api.example.com; - - ssl_certificate /etc/letsencrypt/live/saas-ai-api.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/saas-ai-api.example.com/privkey.pem; - - # OAuth 回调 - location /oauth/ { - proxy_pass http://localhost:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - # Webhook 接收 - location /webhook/ { - proxy_pass http://localhost:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - # 搜索 API - location /api/search/ { - proxy_pass http://localhost:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } -} -``` - -### 11.2 应用审核准备 - -#### 11.2.1 应用商店信息 - -在店匠 Partner 后台填写应用信息: - -**基本信息:** -- APP 名称:AI 智能搜索 -- APP 图标:上传 512x512 PNG 图标 -- APP 分类:Search & Discovery -- 短描述:为您的店铺提供多语言、语义搜索和 AI 推荐功能 -- 详细描述:(500-2000字,介绍功能特性、使用场景、优势) - -**应用截图:** -- 至少 3 张截图(1280x800 或 1920x1080) -- 搜索框界面截图 -- 搜索结果页截图 -- 后台管理界面截图 - -**演示视频:** -- 1-2分钟演示视频 -- 展示 APP 安装、配置、使用流程 - -**定价信息:** -- 免费试用期:14 天 -- 月费:$29.99/月 -- 年费:$299/年(节省 17%) - -#### 11.2.2 测试账号 - -提供测试账号供店匠审核团队测试: - -``` -测试店铺:test-shop-12345.myshoplaza.com -管理员账号:test@example.com -管理员密码:TestPassword123! -``` - -#### 11.2.3 文档准备 - -提供完整的文档: - -- **安装指南**:如何安装和配置 APP -- **使用手册**:如何使用搜索功能 -- **API 文档**:开发者集成文档 -- **FAQ**:常见问题解答 -- **支持联系方式**:support@example.com - -### 11.3 审核和发布 - -#### 11.3.1 提交审核 - -1. 在 Partner 后台点击"提交审核" -2. 填写审核说明 -3. 等待审核结果(通常 3-7 个工作日) - -#### 11.3.2 审核常见问题 - -店匠应用审核的常见拒绝原因: - -1. **功能问题:** - - 核心功能无法正常使用 - - 页面加载速度过慢 - - 移动端适配不良 - -2. **权限问题:** - - 申请了不必要的权限 - - 未说明权限用途 - -3. **UI/UX 问题:** - - 界面与店铺风格不一致 - - 缺少多语言支持 - - 操作流程不清晰 - -4. **文档问题:** - - 缺少必要的文档 - - 文档描述不清楚 - - 测试账号无法访问 - -#### 11.3.3 应用发布 - -审核通过后: - -1. 应用自动发布到店匠应用市场 -2. 商家可以搜索并安装你的 APP -3. 开始正式运营和推广 - ---- - -## 12. 附录 - -### 12.1 API 参考 - -#### 12.1.1 店匠 API 端点速查表 - -| API | 端点 | 方法 | 说明 | -|-----|------|------|------| -| **OAuth** | -| 授权 URL | `/partner/oauth/authorize` | GET | 获取授权 | -| 获取 Token | `/partner/oauth/token` | POST | 换取 Token | -| **商品** | -| 商品列表 | `/openapi/2022-01/products` | GET | 获取商品列表 | -| 商品详情 | `/openapi/2022-01/products/{id}` | GET | 获取单个商品 | -| 商品总数 | `/openapi/2022-01/products/count` | GET | 获取商品总数 | -| **订单** | -| 订单列表 | `/openapi/2022-01/orders` | GET | 获取订单列表 | -| 订单详情 | `/openapi/2022-01/orders/{id}` | GET | 获取单个订单 | -| **客户** | -| 客户列表 | `/openapi/2022-01/customers` | GET | 获取客户列表 | -| 客户详情 | `/openapi/2022-01/customers/{id}` | GET | 获取单个客户 | -| **Webhook** | -| 注册 Webhook | `/openapi/2022-01/webhooks` | POST | 注册事件通知 | -| Webhook 列表 | `/openapi/2022-01/webhooks` | GET | 获取已注册列表 | -| 删除 Webhook | `/openapi/2022-01/webhooks/{id}` | DELETE | 删除 Webhook | - -#### 12.1.2 搜索 API 请求示例 - -**文本搜索:** - -```bash -curl -X POST http://your-domain:6002/search/ \ - -H "Content-Type: application/json" \ - -d '{ - "query": "bluetooth headphone", - "customer": "tenant_1", - "size": 20, - "from": 0, - "filters": { - "product_type": "Electronics" - }, - "facets": ["vendor", "product_type", "tags"] - }' -``` - -**图片搜索:** - -```bash -curl -X POST http://your-domain:6002/search/image \ - -H "Content-Type: application/json" \ - -d '{ - "image_url": "https://example.com/image.jpg", - "customer": "tenant_1", - "size": 20 - }' -``` - -### 12.2 数据库表结构 DDL - -完整的数据库表创建脚本请参考第 4、6 章节中的 SQL 语句。 - -**核心表列表:** -- `system_tenant` - 租户表 -- `shoplazza_shop_config` - 店铺配置表 -- `shoplazza_product_spu` - 商品 SPU 表 -- `shoplazza_product_sku` - 商品 SKU 表 -- `shoplazza_product_image` - 商品图片表 -- `shoplazza_customer` - 客户表 -- `shoplazza_customer_address` - 客户地址表 -- `shoplazza_order` - 订单表 -- `shoplazza_order_item` - 订单明细表 -- `shoplazza_search_log` - 搜索日志表 - -### 12.3 配置示例 - -#### 12.3.1 application.yml 配置 - -```yaml -# OAuth 配置 -shoplazza: - oauth: - client-id: m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es - client-secret: m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo - redirect-uri: https://your-domain.com/oauth/callback - scopes: - - read_shop - - read_product - - read_order - - read_customer - - read_app_proxy - - # Webhook 配置 - webhook: - base-url: https://your-domain.com/webhook/shoplazza - topics: - - products/create - - products/update - - products/delete - - orders/create - - customers/create - -# 搜索服务配置 -search: - service: - url: http://localhost:6002 - timeout: 30000 - -# 向量服务配置 -embedding: - service: - url: http://localhost:6003 - timeout: 60000 - -# Elasticsearch 配置 -elasticsearch: - hosts: localhost:9200 - username: elastic - password: changeme - -# 数据同步配置 -sync: - enabled: true - batch-size: 50 - schedule: - products: "0 0 */1 * * ?" # 每小时 - orders: "0 0 3 * * ?" # 每天凌晨3点 - customers: "0 0 4 * * ?" # 每天凌晨4点 -``` - -### 12.4 故障排查 - -#### 12.4.1 OAuth 认证失败 - -**问题:** 授权回调时报错 "Invalid redirect_uri" - -**解决:** -1. 检查 Partner 后台配置的 Redirect URI 是否与代码中一致 -2. 确保 Redirect URI 使用 HTTPS 协议 -3. 确保 Redirect URI 可公网访问 - -#### 12.4.2 Token 过期 - -**问题:** API 调用返回 401 Unauthorized - -**解决:** -1. 检查数据库中的 `token_expires_at` 字段 -2. 使用 Refresh Token 刷新 Access Token -3. 更新数据库中的 Token 信息 - -#### 12.4.3 API 调用速率限制 - -**问题:** API 返回 429 Too Many Requests - -**解决:** -1. 降低请求频率 -2. 实现指数退避重试 -3. 解析响应头中的 `X-RateLimit-Reset` 字段,等待到指定时间后再重试 - -#### 12.4.4 Webhook 接收失败 - -**问题:** Webhook 事件未收到或签名验证失败 - -**解决:** -1. 检查 Webhook 地址是否可公网访问 -2. 检查签名验证逻辑是否正确使用 Client Secret -3. 查看店匠后台的 Webhook 日志,确认发送状态 -4. 确保 Webhook 处理在 3 秒内返回 200 响应 - -#### 12.4.5 商品搜索无结果 - -**问题:** 搜索返回空结果 - -**解决:** -1. 检查 ES 索引是否存在:`GET /shoplazza_products_1/_count` -2. 检查商品是否已索引:`GET /shoplazza_products_1/_search` -3. 检查租户隔离参数是否正确 -4. 查看搜索服务日志,确认查询语句 - -#### 12.4.6 向量生成失败 - -**问题:** 图片或文本向量生成失败 - -**解决:** -1. 检查向量服务是否正常运行 -2. 检查向量服务的 GPU/CPU 资源是否充足 -3. 检查图片 URL 是否可访问 -4. 查看向量服务日志 - ---- - -## 13. 参考资料 - -### 13.1 官方文档 - -- [店匠开发者文档](https://www.shoplazza.dev/reference/overview-29) -- [店匠 OAuth 文档](https://www.shoplazza.dev/v2024.07/reference/authentication) -- [店匠 API 参考](https://www.shoplazza.dev/v2024.07/reference/overview) -- [店匠 Webhook 文档](https://www.shoplazza.dev/v2024.07/reference/webhooks) - -### 13.2 技术栈文档 - -- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749) -- [Elasticsearch 官方文档](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) -- [Liquid 模板语言](https://shopify.github.io/liquid/) -- [FastAPI 文档](https://fastapi.tiangolo.com/) - -### 13.3 联系支持 - -如有问题,请联系: - -- **技术支持邮箱**: support@example.com -- **开发者社区**: https://community.example.com -- **GitHub Issues**: https://github.com/your-org/search-saas/issues - ---- - -**文档版本**: v1.0 -**最后更新**: 2025-11-12 -**维护团队**: 搜索 SaaS 开发团队 - diff --git a/api/SearchEngine.code-workspace b/api/SearchEngine.code-workspace new file mode 100644 index 0000000..bab1b7f --- /dev/null +++ b/api/SearchEngine.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": ".." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/config/schema/base/config.yaml b/config/schema/base/config.yaml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/config/schema/base/config.yaml diff --git a/docs/店匠相关资料/SHOPLAZZA_INTEGRATION_GUIDE.md b/docs/店匠相关资料/SHOPLAZZA_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..71fffeb --- /dev/null +++ b/docs/店匠相关资料/SHOPLAZZA_INTEGRATION_GUIDE.md @@ -0,0 +1,3226 @@ +# 店匠平台技术对接指南 + +## 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}}

+ +
+

下一步操作:

+
    +
  1. 进入店铺后台 → 主题装修
  2. +
  3. 点击"添加卡片" → 选择"APPS" → 找到"搜索 SaaS"
  4. +
  5. 拖拽搜索组件到页面中
  6. +
  7. 保存并发布主题
  8. +
+
+ + 前往店铺后台 + + +``` + +--- + +## 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> variants = new ArrayList<>(); + for (ProductSku sku : skus) { + Map variant = new HashMap<>(); + variant.put("variant_id", sku.getVariantId()); + variant.put("sku", sku.getSku()); + variant.put("title", sku.getTitle()); + variant.put("price", sku.getPrice()); + variant.put("inventory_quantity", sku.getInventoryQuantity()); + variant.put("option1", sku.getOption1()); + variant.put("option2", sku.getOption2()); + variant.put("option3", sku.getOption3()); + variants.add(variant); + } + doc.put("variants", variants); + + // 使用第一个 SKU 的价格和库存 + ProductSku firstSku = skus.get(0); + doc.put("price", firstSku.getPrice()); + doc.put("inventory_quantity", firstSku.getInventoryQuantity()); + } + + // 图片 + List images = imageMapper.selectByProductId(spu.getProductId()); + if (CollectionUtils.isNotEmpty(images)) { + ProductImage firstImage = images.get(0); + doc.put("image_url", firstImage.getSrc()); + + // 生成图片向量 + try { + float[] imageEmbedding = embeddingService.encodeImage(firstImage.getSrc()); + doc.put("image_embedding", imageEmbedding); + } catch (Exception e) { + log.warn("Failed to encode image: {}", firstImage.getSrc(), e); + } + } + + // 生成标题向量 + try { + float[] titleEmbedding = embeddingService.encodeText(spu.getTitle()); + doc.put("title_embedding", titleEmbedding); + } catch (Exception e) { + log.warn("Failed to encode title: {}", spu.getTitle(), e); + } + + return doc; + } +} +``` + +#### 8.3.2 调用 Python 向量服务 + +向量生成需要调用 Python 服务: + +```java +@Service +public class EmbeddingService { + + @Autowired + private RestTemplate restTemplate; + + @Value("${embedding.service.url}") + private String embeddingServiceUrl; + + /** + * 生成文本向量 + */ + public float[] encodeText(String text) { + try { + String url = embeddingServiceUrl + "/encode/text"; + + Map request = new HashMap<>(); + request.put("text", text); + + ResponseEntity response = restTemplate.postForEntity( + url, + request, + EmbeddingResponse.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + return response.getBody().getEmbedding(); + } + + throw new BusinessException("Failed to encode text"); + + } catch (Exception e) { + log.error("Failed to call embedding service", e); + throw new BusinessException("Embedding service error", e); + } + } + + /** + * 生成图片向量 + */ + public float[] encodeImage(String imageUrl) { + try { + String url = embeddingServiceUrl + "/encode/image"; + + Map request = new HashMap<>(); + request.put("image_url", imageUrl); + + ResponseEntity response = restTemplate.postForEntity( + url, + request, + EmbeddingResponse.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + return response.getBody().getEmbedding(); + } + + throw new BusinessException("Failed to encode image"); + + } catch (Exception e) { + log.error("Failed to call embedding service", e); + throw new BusinessException("Embedding service error", e); + } + } +} +``` + +### 8.4 增量索引更新 + +Webhook 触发增量更新: + +```java +public void indexSingleProduct(Long tenantId, String productId) { + String indexName = String.format("shoplazza_products_%d", tenantId); + + ProductSpu spu = spuMapper.selectByProductId(productId); + if (spu == null) { + log.warn("Product not found: {}", productId); + return; + } + + try { + // 构建文档 + Map doc = buildEsDocument(tenantId, spu); + + // 索引文档 + IndexRequest request = new IndexRequest(indexName) + .id(productId) + .source(doc); + + esClient.index(request, RequestOptions.DEFAULT); + + log.info("Indexed product: {}", productId); + + } catch (Exception e) { + log.error("Failed to index product: {}", productId, e); + } +} + +public void deleteProduct(Long tenantId, String productId) { + String indexName = String.format("shoplazza_products_%d", tenantId); + + try { + DeleteRequest request = new DeleteRequest(indexName, productId); + esClient.delete(request, RequestOptions.DEFAULT); + + log.info("Deleted product from ES: {}", productId); + + } catch (Exception e) { + log.error("Failed to delete product from ES: {}", productId, e); + } +} +``` + +--- + +## 9. 搜索服务集成 + +### 9.1 搜索 API 调用 + +Java 后端接收前端搜索请求后,转发给 Python 搜索服务: + +```java +@RestController +@RequestMapping("/api/search") +public class SearchController { + + @Autowired + private SearchService searchService; + + @PostMapping("/products") + public ResponseEntity searchProducts( + @RequestParam String storeId, + @RequestBody SearchRequest request) { + + try { + // 查询店铺配置,获取 tenant_id + ShopConfig shop = shopConfigMapper.selectByStoreId(storeId); + if (shop == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); + } + + // 调用 Python 搜索服务 + SearchResponse response = searchService.search(shop.getTenantId(), request); + + // 记录搜索日志 + searchLogService.logSearch(shop.getId(), request.getQuery(), response.getTotal()); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("Search failed: storeId={}, query={}", storeId, request.getQuery(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); + } + } +} + +@Service +public class SearchService { + + @Autowired + private RestTemplate restTemplate; + + @Value("${search.service.url}") + private String searchServiceUrl; + + /** + * 调用 Python 搜索服务 + */ + public SearchResponse search(Long tenantId, SearchRequest request) { + try { + String url = searchServiceUrl + "/search/"; + + // 添加租户隔离参数 + request.setCustomer("tenant_" + tenantId); + + ResponseEntity response = restTemplate.postForEntity( + url, + request, + SearchResponse.class + ); + + if (response.getStatusCode().is2xxSuccessful()) { + return response.getBody(); + } + + throw new BusinessException("Search service returned error: " + response.getStatusCode()); + + } catch (Exception e) { + log.error("Failed to call search service", e); + throw new BusinessException("Search service error", e); + } + } +} +``` + +### 9.2 店铺隔离 + +每个店铺对应一个租户,使用不同的 ES 索引: + +```python +# Python 搜索服务 +@app.post("/search/") +async def search_products(request: SearchRequest): + # 根据 customer 参数确定租户 ID + tenant_id = extract_tenant_id(request.customer) + + # 使用租户专属索引 + index_name = f"shoplazza_products_{tenant_id}" + + # 构建 ES 查询 + es_query = build_es_query(request) + + # 执行搜索 + response = es_client.search( + index=index_name, + body=es_query + ) + + # 返回结果 + return format_search_response(response) +``` + +### 9.3 搜索行为统计 + +#### 9.3.1 日志表设计 + +```sql +CREATE TABLE `shoplazza_search_log` ( + `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) DEFAULT NULL COMMENT '客户ID', + `session_id` VARCHAR(128) DEFAULT NULL COMMENT '会话ID', + `query` VARCHAR(512) NOT NULL COMMENT '搜索关键词', + `results_count` INT DEFAULT 0 COMMENT '结果数量', + `search_type` VARCHAR(32) DEFAULT 'text' COMMENT '搜索类型:text, image, ai', + `language` VARCHAR(16) DEFAULT NULL COMMENT '搜索语言', + `has_results` BIT(1) DEFAULT b'1' COMMENT '是否有结果', + `response_time_ms` INT DEFAULT NULL COMMENT '响应时间(毫秒)', + `ip_address` VARCHAR(64) DEFAULT NULL COMMENT 'IP地址', + `user_agent` VARCHAR(512) DEFAULT NULL COMMENT 'User Agent', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_store_id` (`store_id`), + KEY `idx_query` (`query`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠搜索日志表'; +``` + +#### 9.3.2 日志记录实现 + +```java +@Service +public class SearchLogService { + + @Autowired + private SearchLogMapper searchLogMapper; + + /** + * 记录搜索日志 + */ + @Async + public void logSearch(Long shopConfigId, SearchRequest request, SearchResponse response, + long responseTime, HttpServletRequest httpRequest) { + try { + ShopConfig shop = shopConfigMapper.selectById(shopConfigId); + + SearchLog log = new SearchLog(); + log.setTenantId(shop.getTenantId()); + log.setStoreId(shop.getStoreId()); + log.setCustomerId(request.getCustomerId()); + log.setSessionId(request.getSessionId()); + log.setQuery(request.getQuery()); + log.setResultsCount(response.getTotal()); + log.setSearchType(request.getSearchType()); + log.setLanguage(request.getLanguage()); + log.setHasResults(response.getTotal() > 0); + log.setResponseTimeMs((int) responseTime); + log.setIpAddress(getClientIp(httpRequest)); + log.setUserAgent(httpRequest.getHeader("User-Agent")); + + searchLogMapper.insert(log); + + } catch (Exception e) { + log.error("Failed to log search", e); + } + } + + /** + * 统计分析:热门搜索词 + */ + public List getHotQueries(String storeId, int limit) { + return searchLogMapper.selectHotQueries(storeId, limit); + } + + /** + * 统计分析:无结果搜索 + */ + public List getNoResultQueries(String storeId, int limit) { + return searchLogMapper.selectNoResultQueries(storeId, limit); + } +} +``` + +--- + +## 10. 前端扩展开发 + +### 10.1 主题扩展开发 + +店匠使用 Liquid 模板语言开发主题扩展。 + +#### 10.1.1 创建扩展项目 + +```bash +mkdir shoplazza-ai-search-app +cd shoplazza-ai-search-app + +# 目录结构 +├── app-blocks/ +│ ├── search-box.liquid # 搜索框组件 +│ ├── search-results.liquid # 搜索结果组件 +│ └── settings.json # 组件配置 +├── assets/ +│ ├── search-box.js # JavaScript +│ ├── search-box.css # 样式 +│ └── search-results.js +├── locales/ +│ ├── en.json # 英文翻译 +│ ├── zh-CN.json # 中文翻译 +│ └── es.json # 西班牙语翻译 +└── config.json # APP 配置 +``` + +#### 10.1.2 搜索框组件(search-box.liquid) + +```liquid + + + + + + +``` + +#### 10.1.3 搜索框 JavaScript(search-box.js) + +```javascript +// 搜索框功能 +(function() { + const config = window.AI_SEARCH_CONFIG || {}; + let searchTimeout; + + function handleSearch(event) { + event.preventDefault(); + const query = event.target.q.value.trim(); + + if (!query) return false; + + // 跳转到搜索结果页 + window.location.href = `/pages/search-results?q=${encodeURIComponent(query)}`; + return false; + } + + // 搜索建议(自动补全) + function setupAutocomplete() { + const input = document.querySelector('.search-input'); + const suggestionsContainer = document.getElementById('search-suggestions'); + + if (!input || !suggestionsContainer) return; + + input.addEventListener('input', function(e) { + clearTimeout(searchTimeout); + const query = e.target.value.trim(); + + if (query.length < 2) { + suggestionsContainer.innerHTML = ''; + suggestionsContainer.style.display = 'none'; + return; + } + + searchTimeout = setTimeout(() => { + fetchSuggestions(query); + }, 300); + }); + + // 点击外部关闭建议 + document.addEventListener('click', function(e) { + if (!e.target.closest('.ai-search-box')) { + suggestionsContainer.style.display = 'none'; + } + }); + } + + async function fetchSuggestions(query) { + try { + const response = await fetch(`${config.apiEndpoint}/suggestions?q=${encodeURIComponent(query)}&store_id=${config.storeId}`); + const data = await response.json(); + + if (data.suggestions && data.suggestions.length > 0) { + renderSuggestions(data.suggestions); + } + } catch (error) { + console.error('Failed to fetch suggestions:', error); + } + } + + function renderSuggestions(suggestions) { + const container = document.getElementById('search-suggestions'); + + const html = suggestions.map(item => ` +
+ ${item.text} +
+ `).join(''); + + container.innerHTML = html; + container.style.display = 'block'; + } + + window.selectSuggestion = function(text) { + document.querySelector('.search-input').value = text; + document.getElementById('search-suggestions').style.display = 'none'; + document.querySelector('.search-form').submit(); + }; + + window.handleSearch = handleSearch; + + // 初始化 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setupAutocomplete); + } else { + setupAutocomplete(); + } +})(); +``` + +#### 10.1.4 搜索结果页(search-results.liquid) + +```liquid +
+
+

{{ 'search.title' | t }}

+
+ {{ 'search.results_for' | t }}: +
+
+ +
+ +
+ +
+
{{ 'search.loading' | t }}
+
+ +
+
+ + + + + +``` + +#### 10.1.5 搜索结果 JavaScript(search-results.js) + +```javascript +(function() { + const config = window.AI_SEARCH_CONFIG || {}; + let currentPage = 1; + let currentQuery = ''; + let currentFilters = {}; + + // 从 URL 获取搜索参数 + function getSearchParams() { + const params = new URLSearchParams(window.location.search); + return { + query: params.get('q') || '', + page: parseInt(params.get('page')) || 1 + }; + } + + // 执行搜索 + async function performSearch() { + const params = getSearchParams(); + currentQuery = params.query; + currentPage = params.page; + + if (!currentQuery) { + showError('Please enter a search query'); + return; + } + + document.getElementById('current-query').textContent = currentQuery; + showLoading(); + + try { + const response = await fetch(config.apiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + query: currentQuery, + page: currentPage, + size: 24, + filters: currentFilters, + facets: ['product_type', 'vendor', 'tags'], + customer: `tenant_${config.storeId}` + }) + }); + + const data = await response.json(); + + if (data.results) { + renderResults(data.results); + renderFacets(data.facets); + renderPagination(data.total, currentPage, 24); + } else { + showError('No results found'); + } + } catch (error) { + console.error('Search failed:', error); + showError('Search failed. Please try again.'); + } + } + + // 渲染搜索结果 + function renderResults(results) { + const container = document.getElementById('search-results'); + + if (results.length === 0) { + container.innerHTML = '
No products found
'; + return; + } + + const html = results.map(product => ` + + `).join(''); + + container.innerHTML = html; + } + + // 渲染分面过滤器 + function renderFacets(facets) { + const container = document.getElementById('search-filters'); + + if (!facets || Object.keys(facets).length === 0) { + container.innerHTML = ''; + return; + } + + let html = '
Filters
'; + + for (const [field, values] of Object.entries(facets)) { + if (values.length === 0) continue; + + html += ` +
+

${formatFieldName(field)}

+
+ ${values.map(item => ` + + `).join('')} +
+
+ `; + } + + container.innerHTML = html; + } + + // 切换过滤器 + window.toggleFilter = function(field, value) { + if (!currentFilters[field]) { + currentFilters[field] = []; + } + + const index = currentFilters[field].indexOf(value); + if (index > -1) { + currentFilters[field].splice(index, 1); + if (currentFilters[field].length === 0) { + delete currentFilters[field]; + } + } else { + currentFilters[field].push(value); + } + + currentPage = 1; + performSearch(); + }; + + // 渲染分页 + function renderPagination(total, page, pageSize) { + const container = document.getElementById('search-pagination'); + const totalPages = Math.ceil(total / pageSize); + + if (totalPages <= 1) { + container.innerHTML = ''; + return; + } + + let html = ''; + container.innerHTML = html; + } + + // 工具函数 + function formatPrice(price, currency) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency || 'USD' + }).format(price); + } + + function formatFieldName(field) { + return field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + } + + function showLoading() { + document.getElementById('search-results').innerHTML = '
Loading...
'; + } + + function showError(message) { + document.getElementById('search-results').innerHTML = `
${message}
`; + } + + // 初始化 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', performSearch); + } else { + performSearch(); + } +})(); +``` + +### 10.2 多语言支持 + +#### 10.2.1 中文翻译(locales/zh-CN.json) + +```json +{ + "search": { + "placeholder": "搜索商品...", + "title": "搜索结果", + "results_for": "搜索", + "loading": "加载中...", + "no_results": "未找到相关商品", + "filters": "筛选", + "clear_filters": "清除筛选" + } +} +``` + +#### 10.2.2 英文翻译(locales/en.json) + +```json +{ + "search": { + "placeholder": "Search products...", + "title": "Search Results", + "results_for": "Search results for", + "loading": "Loading...", + "no_results": "No products found", + "filters": "Filters", + "clear_filters": "Clear filters" + } +} +``` + +### 10.3 主题装修集成 + +商家可以在店铺后台的主题装修中添加搜索扩展: + +1. 进入店铺后台 → 主题 → 装修 +2. 点击"添加卡片" +3. 选择"APPS"分类 +4. 找到"AI 搜索" APP +5. 拖拽"搜索框"组件到导航栏或页面顶部 +6. 创建自定义页面"搜索结果",添加"搜索结果"组件 +7. 保存并发布主题 + +--- + +## 11. 部署和上线 + +### 11.1 域名和 SSL 配置 + +#### 11.1.1 域名申请 + +申请一个公网域名,例如: +``` +saas-ai-api.example.com +``` + +#### 11.1.2 SSL 证书配置 + +使用 Let's Encrypt 或其他 CA 颁发的 SSL 证书: + +```bash +# 使用 Certbot 申请证书 +sudo apt-get install certbot +sudo certbot certonly --standalone -d saas-ai-api.example.com +``` + +#### 11.1.3 Nginx 配置 + +```nginx +server { + listen 443 ssl http2; + server_name saas-ai-api.example.com; + + ssl_certificate /etc/letsencrypt/live/saas-ai-api.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/saas-ai-api.example.com/privkey.pem; + + # OAuth 回调 + location /oauth/ { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Webhook 接收 + location /webhook/ { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # 搜索 API + location /api/search/ { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +### 11.2 应用审核准备 + +#### 11.2.1 应用商店信息 + +在店匠 Partner 后台填写应用信息: + +**基本信息:** +- APP 名称:AI 智能搜索 +- APP 图标:上传 512x512 PNG 图标 +- APP 分类:Search & Discovery +- 短描述:为您的店铺提供多语言、语义搜索和 AI 推荐功能 +- 详细描述:(500-2000字,介绍功能特性、使用场景、优势) + +**应用截图:** +- 至少 3 张截图(1280x800 或 1920x1080) +- 搜索框界面截图 +- 搜索结果页截图 +- 后台管理界面截图 + +**演示视频:** +- 1-2分钟演示视频 +- 展示 APP 安装、配置、使用流程 + +**定价信息:** +- 免费试用期:14 天 +- 月费:$29.99/月 +- 年费:$299/年(节省 17%) + +#### 11.2.2 测试账号 + +提供测试账号供店匠审核团队测试: + +``` +测试店铺:test-shop-12345.myshoplaza.com +管理员账号:test@example.com +管理员密码:TestPassword123! +``` + +#### 11.2.3 文档准备 + +提供完整的文档: + +- **安装指南**:如何安装和配置 APP +- **使用手册**:如何使用搜索功能 +- **API 文档**:开发者集成文档 +- **FAQ**:常见问题解答 +- **支持联系方式**:support@example.com + +### 11.3 审核和发布 + +#### 11.3.1 提交审核 + +1. 在 Partner 后台点击"提交审核" +2. 填写审核说明 +3. 等待审核结果(通常 3-7 个工作日) + +#### 11.3.2 审核常见问题 + +店匠应用审核的常见拒绝原因: + +1. **功能问题:** + - 核心功能无法正常使用 + - 页面加载速度过慢 + - 移动端适配不良 + +2. **权限问题:** + - 申请了不必要的权限 + - 未说明权限用途 + +3. **UI/UX 问题:** + - 界面与店铺风格不一致 + - 缺少多语言支持 + - 操作流程不清晰 + +4. **文档问题:** + - 缺少必要的文档 + - 文档描述不清楚 + - 测试账号无法访问 + +#### 11.3.3 应用发布 + +审核通过后: + +1. 应用自动发布到店匠应用市场 +2. 商家可以搜索并安装你的 APP +3. 开始正式运营和推广 + +--- + +## 12. 附录 + +### 12.1 API 参考 + +#### 12.1.1 店匠 API 端点速查表 + +| API | 端点 | 方法 | 说明 | +|-----|------|------|------| +| **OAuth** | +| 授权 URL | `/partner/oauth/authorize` | GET | 获取授权 | +| 获取 Token | `/partner/oauth/token` | POST | 换取 Token | +| **商品** | +| 商品列表 | `/openapi/2022-01/products` | GET | 获取商品列表 | +| 商品详情 | `/openapi/2022-01/products/{id}` | GET | 获取单个商品 | +| 商品总数 | `/openapi/2022-01/products/count` | GET | 获取商品总数 | +| **订单** | +| 订单列表 | `/openapi/2022-01/orders` | GET | 获取订单列表 | +| 订单详情 | `/openapi/2022-01/orders/{id}` | GET | 获取单个订单 | +| **客户** | +| 客户列表 | `/openapi/2022-01/customers` | GET | 获取客户列表 | +| 客户详情 | `/openapi/2022-01/customers/{id}` | GET | 获取单个客户 | +| **Webhook** | +| 注册 Webhook | `/openapi/2022-01/webhooks` | POST | 注册事件通知 | +| Webhook 列表 | `/openapi/2022-01/webhooks` | GET | 获取已注册列表 | +| 删除 Webhook | `/openapi/2022-01/webhooks/{id}` | DELETE | 删除 Webhook | + +#### 12.1.2 搜索 API 请求示例 + +**文本搜索:** + +```bash +curl -X POST http://your-domain:6002/search/ \ + -H "Content-Type: application/json" \ + -d '{ + "query": "bluetooth headphone", + "customer": "tenant_1", + "size": 20, + "from": 0, + "filters": { + "product_type": "Electronics" + }, + "facets": ["vendor", "product_type", "tags"] + }' +``` + +**图片搜索:** + +```bash +curl -X POST http://your-domain:6002/search/image \ + -H "Content-Type: application/json" \ + -d '{ + "image_url": "https://example.com/image.jpg", + "customer": "tenant_1", + "size": 20 + }' +``` + +### 12.2 数据库表结构 DDL + +完整的数据库表创建脚本请参考第 4、6 章节中的 SQL 语句。 + +**核心表列表:** +- `system_tenant` - 租户表 +- `shoplazza_shop_config` - 店铺配置表 +- `shoplazza_product_spu` - 商品 SPU 表 +- `shoplazza_product_sku` - 商品 SKU 表 +- `shoplazza_product_image` - 商品图片表 +- `shoplazza_customer` - 客户表 +- `shoplazza_customer_address` - 客户地址表 +- `shoplazza_order` - 订单表 +- `shoplazza_order_item` - 订单明细表 +- `shoplazza_search_log` - 搜索日志表 + +### 12.3 配置示例 + +#### 12.3.1 application.yml 配置 + +```yaml +# OAuth 配置 +shoplazza: + oauth: + client-id: m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es + client-secret: m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo + redirect-uri: https://your-domain.com/oauth/callback + scopes: + - read_shop + - read_product + - read_order + - read_customer + - read_app_proxy + + # Webhook 配置 + webhook: + base-url: https://your-domain.com/webhook/shoplazza + topics: + - products/create + - products/update + - products/delete + - orders/create + - customers/create + +# 搜索服务配置 +search: + service: + url: http://localhost:6002 + timeout: 30000 + +# 向量服务配置 +embedding: + service: + url: http://localhost:6003 + timeout: 60000 + +# Elasticsearch 配置 +elasticsearch: + hosts: localhost:9200 + username: elastic + password: changeme + +# 数据同步配置 +sync: + enabled: true + batch-size: 50 + schedule: + products: "0 0 */1 * * ?" # 每小时 + orders: "0 0 3 * * ?" # 每天凌晨3点 + customers: "0 0 4 * * ?" # 每天凌晨4点 +``` + +### 12.4 故障排查 + +#### 12.4.1 OAuth 认证失败 + +**问题:** 授权回调时报错 "Invalid redirect_uri" + +**解决:** +1. 检查 Partner 后台配置的 Redirect URI 是否与代码中一致 +2. 确保 Redirect URI 使用 HTTPS 协议 +3. 确保 Redirect URI 可公网访问 + +#### 12.4.2 Token 过期 + +**问题:** API 调用返回 401 Unauthorized + +**解决:** +1. 检查数据库中的 `token_expires_at` 字段 +2. 使用 Refresh Token 刷新 Access Token +3. 更新数据库中的 Token 信息 + +#### 12.4.3 API 调用速率限制 + +**问题:** API 返回 429 Too Many Requests + +**解决:** +1. 降低请求频率 +2. 实现指数退避重试 +3. 解析响应头中的 `X-RateLimit-Reset` 字段,等待到指定时间后再重试 + +#### 12.4.4 Webhook 接收失败 + +**问题:** Webhook 事件未收到或签名验证失败 + +**解决:** +1. 检查 Webhook 地址是否可公网访问 +2. 检查签名验证逻辑是否正确使用 Client Secret +3. 查看店匠后台的 Webhook 日志,确认发送状态 +4. 确保 Webhook 处理在 3 秒内返回 200 响应 + +#### 12.4.5 商品搜索无结果 + +**问题:** 搜索返回空结果 + +**解决:** +1. 检查 ES 索引是否存在:`GET /shoplazza_products_1/_count` +2. 检查商品是否已索引:`GET /shoplazza_products_1/_search` +3. 检查租户隔离参数是否正确 +4. 查看搜索服务日志,确认查询语句 + +#### 12.4.6 向量生成失败 + +**问题:** 图片或文本向量生成失败 + +**解决:** +1. 检查向量服务是否正常运行 +2. 检查向量服务的 GPU/CPU 资源是否充足 +3. 检查图片 URL 是否可访问 +4. 查看向量服务日志 + +--- + +## 13. 参考资料 + +### 13.1 官方文档 + +- [店匠开发者文档](https://www.shoplazza.dev/reference/overview-29) +- [店匠 OAuth 文档](https://www.shoplazza.dev/v2024.07/reference/authentication) +- [店匠 API 参考](https://www.shoplazza.dev/v2024.07/reference/overview) +- [店匠 Webhook 文档](https://www.shoplazza.dev/v2024.07/reference/webhooks) + +### 13.2 技术栈文档 + +- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749) +- [Elasticsearch 官方文档](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) +- [Liquid 模板语言](https://shopify.github.io/liquid/) +- [FastAPI 文档](https://fastapi.tiangolo.com/) + +### 13.3 联系支持 + +如有问题,请联系: + +- **技术支持邮箱**: support@example.com +- **开发者社区**: https://community.example.com +- **GitHub Issues**: https://github.com/your-org/search-saas/issues + +--- + +**文档版本**: v1.0 +**最后更新**: 2025-11-12 +**维护团队**: 搜索 SaaS 开发团队 + diff --git a/docs/店匠相关资料/店匠官方参考文档.md b/docs/店匠相关资料/店匠官方参考文档.md new file mode 100644 index 0000000..36e1bab --- /dev/null +++ b/docs/店匠相关资料/店匠官方参考文档.md @@ -0,0 +1,13 @@ +### 13.1 官方文档 + +- [店匠开发者文档](https://www.shoplazza.dev/reference/overview-29) +- [店匠 OAuth 文档](https://www.shoplazza.dev/v2024.07/reference/authentication) +- [店匠 API 参考](https://www.shoplazza.dev/v2024.07/reference/overview) +- [店匠 Webhook 文档](https://www.shoplazza.dev/v2024.07/reference/webhooks) + +### 13.2 技术栈文档 + +- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749) +- [Elasticsearch 官方文档](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) +- [Liquid 模板语言](https://shopify.github.io/liquid/) +- [FastAPI 文档](https://fastapi.tiangolo.com/) diff --git a/docs/店匠相关资料/搜索web后端调用python搜索接口.md b/docs/店匠相关资料/搜索web后端调用python搜索接口.md new file mode 100644 index 0000000..25f7336 --- /dev/null +++ b/docs/店匠相关资料/搜索web后端调用python搜索接口.md @@ -0,0 +1,261 @@ +非常好的问题!让我详细解释一下搜索应用的调用链路和 OAuth 的关系。 + +## 调用链路说明 + +### 1. **OAuth 认证的作用** + +OAuth **不是用于前端搜索调用**的,它主要用于: + +```mermaid +graph LR + A[商家安装APP] --> B[OAuth授权] + B --> C[获取Access Token] + C --> D[后端拉取商品数据] + D --> E[建立ES索引] + E --> F[搜索服务就绪] +``` + +**OAuth Token 的用途:** +- ✅ 你的后端调用店匠 Admin API(拉取商品、订单、客户数据) +- ✅ 注册 Webhook(接收数据变更通知) +- ❌ **不用于**前端买家搜索时的调用 + +### 2. **前端搜索的实际调用链路** + +当买家在店铺搜索商品时: + +``` +买家浏览器 → 搜索框组件(Liquid/JS) → 你的搜索API → Elasticsearch → 返回结果 +``` + +**关键点:** +- 前端 JavaScript **直接调用**你的公开搜索 API +- 不需要 OAuth Token +- 需要传递 `store_id` 参数来标识是哪个店铺 + +### 3. **搜索接口的两种方案** + +[2 tools called] + +## 详细解答 + +### 📍 **方案 A:前端直接调用(推荐用于公开搜索)** + +**流程:** + +```javascript +// 在店铺前端页面(买家浏览器中) +const response = await fetch('https://your-domain.com/api/search/products', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + query: "蓝牙耳机", + tenant: "tenant_47167113-1", // 店铺标识 + size: 24, + filters: {}, + facets: ['product_type', 'vendor'] + }) +}); +``` + +**你的搜索 API 需要:** + +1. **允许跨域访问(CORS)**: +```python +# Python FastAPI 示例 +from fastapi.middleware.cors import CORSMiddleware + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 或指定店匠域名白名单 + allow_credentials=True, + allow_methods=["POST"], + allow_headers=["*"], +) +``` + +2. **根据 store_id 隔离数据**: +```python +@app.post("/api/search/products") +async def search(request: SearchRequest): + # 从 tenant 参数提取 tenant_id + tenant_id = extract_tenant_id(request.tenant) + + # 使用租户专属索引 + index_name = f"shoplazza_products_{tenant_id}" + + # 执行搜索 + results = es_client.search(index=index_name, body=query) + return results +``` + +3. **不需要 OAuth Token 认证**(因为是公开查询) + +--- + +### 📍 **方案 B:通过 Java 后端中转(更安全)** + +**流程:** + +``` +买家浏览器 → Java后端(/api/search/products?storeId=xxx) → Python搜索服务 → ES +``` + +**Java 后端代码:** + +```java +@RestController +@RequestMapping("/api/search") +public class SearchController { + + @PostMapping("/products") + public ResponseEntity search( + @RequestParam String storeId, // 从URL参数获取店铺ID + @RequestBody SearchRequest request) { + + // 1. 验证店铺ID(可选:检查域名白名单) + ShopConfig shop = shopConfigMapper.selectByStoreId(storeId); + if (shop == null) { + return ResponseEntity.notFound().build(); + } + + // 2. 添加租户隔离参数 + request.setTenant("tenant_" + shop.getTenantId()); + + // 3. 调用 Python 搜索服务 + SearchResponse response = restTemplate.postForObject( + "http://localhost:6002/search/", + request, + SearchResponse.class + ); + + // 4. 记录搜索日志 + searchLogService.logSearch(shop.getId(), request.getQuery(), response.getTotal()); + + return ResponseEntity.ok(response); + } +} +``` + +**前端调用(带 store_id):** + +```javascript +const response = await fetch( + `https://your-domain.com/api/search/products?storeId=${config.storeId}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: "蓝牙耳机", + size: 24, + filters: {}, + facets: ['product_type', 'vendor'] + }) + } +); +``` + +--- + +## 🔐 OAuth 认证在整个系统中的位置 + +```mermaid +graph TB + subgraph "1. 商家安装阶段(使用OAuth)" + A[商家安装APP] --> B[OAuth授权] + B --> C[获取Access Token] + C --> D[存储Token到数据库] + end + + subgraph "2. 数据准备阶段(使用OAuth Token)" + D --> E[定时任务启动] + E --> F[使用Token调用店匠API] + F --> G[拉取商品/订单数据] + G --> H[建立ES索引] + end + + subgraph "3. 买家搜索阶段(不需要OAuth)" + I[买家访问店铺] --> J[输入搜索词] + J --> K[前端JS直接调用搜索API] + K --> L[搜索ES索引] + L --> M[返回结果] + end + + H -.索引建立后.-> L +``` + +**关键理解:** +- **OAuth Token** = 你的后端 ↔ 店匠 Admin API(拉数据用) +- **前端搜索** = 买家浏览器 ↔ 你的搜索 API(不需要 OAuth) + +--- + +## ✅ 你需要做的事情 + +### 1. **搜索 API 设计** + +当前你的 Python 搜索服务应该已经支持: + +```python +POST http://your-domain:6002/search/ +Content-Type: application/json + +{ + "query": "蓝牙耳机", + "tenant": "tenant_1", // 重要!用于隔离不同店铺 + "size": 20, + "filters": {}, + "facets": ["product_type", "vendor"] +} +``` + +### 2. **CORS 配置**(如果前端直接调用) + +在 Python FastAPI 服务中添加: + +```python +from fastapi.middleware.cors import CORSMiddleware + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://*.myshoplaza.com", # 店匠域名 + "https://your-domain.com" + ], + allow_methods=["POST", "GET"], + allow_headers=["*"], +) +``` + +### 3. **店铺标识传递** + +在前端 Liquid 模板中获取店铺域名: + +```liquid + +``` + +### 4. **安全性考虑** + +- ✅ 租户隔离:根据 `tenant` 参数查询不同索引 +- ✅ 域名白名单:只允许店匠域名调用 +- ✅ 速率限制:防止恶意请求 +- ❌ **不需要**在每次搜索时验证 OAuth Token + +--- + +## 🎯 总结 + +1. **OAuth 只用于后台数据同步**,不用于前端搜索 +2. **前端搜索直接调用你的公开 API**,通过 `storeId` 参数隔离数据 +3. **你的搜索接口可以是无认证的**,但必须做好租户隔离 +4. **推荐方案**:前端 → 你的 Java 后端 → Python 搜索服务(便于日志统计和安全控制) + +有其他疑问吗? \ No newline at end of file diff --git a/docs/店匠相关资料/记录tenant和token-获取商品信息.md b/docs/店匠相关资料/记录tenant和token-获取商品信息.md new file mode 100644 index 0000000..4c995c3 --- /dev/null +++ b/docs/店匠相关资料/记录tenant和token-获取商品信息.md @@ -0,0 +1,426 @@ +让我详细解释这些任务,并查看相关的代码实现: + +[3 tools called] + +## 📋 详细任务解释 + +让我逐一解释这些开发任务: + +--- + +## 1️⃣ **OAuth 的迁移 - 参考 Go 的实现** + +**任务目标:** 将现有的 Go 语言 OAuth 实现迁移到 Java 项目中 + +**具体工作:** +```java +// 需要实现的 Java 端点: +GET /oauth/install?shop={shop_domain} // 处理APP安装请求 +GET /oauth/callback?code=xxx&shop=xxx // 处理OAuth授权回调 +POST /partner/oauth/token // 换取/刷新 Access Token +``` + +**参考的 Go 代码功能:** +- 生成授权 URL 并重定向 +- 处理授权回调 +- 用 code 换取 Access Token +- 解析 Token 响应并存储 + +--- + +## 2️⃣ **AccessToken 的存储 - 存储到 shoplazza_shop_config** + +**任务目标:** OAuth 成功后,将 Token 信息保存到数据库 + +**示意图:** +``` +店匠平台 搜索SaaS平台 +----------- ---------------- +[应用市场] + ↓ +[商家安装APP] --------→ OAuth授权流程 + ↓ ↓ +[商家授权成功] --------→ 【第2项】创建租户+存储Token + ↓ + system_tenant (新建) + shoplazza_shop_config (新建) + 存储 AccessToken 和 RefreshToken + ↓ + 【第3项】定时刷新Token +``` + + Token 的获取和使用流程 + +```mermaid +sequenceDiagram + participant 商家 + participant 店匠 + participant 你的后端 + participant 数据库 + + Note over 商家,你的后端: 1. OAuth 授权阶段 + 商家->>店匠: 安装 APP + 店匠->>你的后端: 跳转授权 + 商家->>店匠: 同意授权 + 店匠->>你的后端: 回调带 code + 你的后端->>店匠: 用 code 换 Token + 店匠->>你的后端: 返回 Access Token + 你的后端->>数据库: 存储到 shoplazza_shop_config + + Note over 你的后端,数据库: 2. 注册 Webhook 阶段 + 你的后端->>数据库: 读取 Access Token + 你的后端->>店匠: 注册 Webhook (带 Access Token) + 店匠->>你的后端: Webhook 注册成功 +``` + +**核心逻辑:** +```java +@Transactional +public void handleOAuthCallback(TokenResponse tokenResponse) { + // 1. 检查租户是否存在,不存在则创建 + Tenant tenant = tenantMapper.selectByStoreId(storeId); + if (tenant == null) { + tenant = new Tenant(); + tenant.setName(storeName); + 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); + } + + // 3. 保存 Token 信息 + shop.setAccessToken(tokenResponse.getAccessToken()); // 👈 存储 + shop.setRefreshToken(tokenResponse.getRefreshToken()); // 👈 存储 + shop.setTokenExpiresAt(tokenResponse.getExpiresAt()); // 👈 存储 + shop.setLocale(tokenResponse.getLocale()); + shop.setStatus("active"); + + shopConfigMapper.insertOrUpdate(shop); +} +``` + +**数据表:** `shoplazza_shop_config`(已设计在文档第4章) + +### 📊 token数据库表关系 + +```sql +-- shoplazza_shop_config 表中存储的数据 ++----------+----------------+----------------------------------------+ +| store_id | store_name | access_token | ++----------+----------------+----------------------------------------+ +| 2286274 | 47167113-1 | V2WDYgkTvrN68QCESZ9eHb3EjpR6EB... | 👈 OAuth时保存 ++----------+----------------+----------------------------------------+ + ↓ + 注册 Webhook 时读取使用 +``` + +### 🔐 Token 的两种用途 + +**这个 Access Token 在你的系统中有两大用途:** + +1. **拉取数据** - 调用店匠 Admin API + - 拉取商品:`GET /openapi/2022-01/products` + - 拉取订单:`GET /openapi/2022-01/orders` + - 拉取客户:`GET /openapi/2022-01/customers` + +2. **注册 Webhook** - 让店匠主动推送数据变更 + - 注册:`POST /openapi/2022-01/webhooks`(需要 Token) + - 接收:店匠推送到你的 `/webhook/shoplazza/{storeId}` 端点(不需要 Token) + +### ⚠️ 注意事项 + +```java +// 注册 Webhook 前,确保 Token 有效 +public void registerWebhooks(Long shopConfigId) { + ShopConfig shop = shopConfigMapper.selectById(shopConfigId); + + // 检查 Token 是否过期 + if (shop.getTokenExpiresAt().before(new Date())) { + // Token 已过期,先刷新 + tokenService.refreshToken(shop); + shop = shopConfigMapper.selectById(shopConfigId); // 重新读取 + } + + // 使用有效的 Token 注册 Webhook + String accessToken = shop.getAccessToken(); + // ... 注册逻辑 +} +``` + +--- + +## 3️⃣ **RefreshToken 的实现 - 基于定时任务,需考虑对多家店铺的处理** + +**任务目标:** 自动刷新即将过期的 Access Token + +**实现方式:** + +```java +@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行 +public void refreshExpiringTokens() { + // 1. 查询7天内过期的所有店铺 + DateTime sevenDaysLater = DateTime.now().plusDays(7); + List shops = shopConfigMapper.selectExpiringTokens(sevenDaysLater); + + // 2. 遍历每个店铺,刷新 Token + for (ShopConfig shop : shops) { + try { + TokenResponse newToken = oauthClient.refreshToken( + shop.getRefreshToken(), + clientId, + clientSecret + ); + + // 3. 更新数据库中的 Token + 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); + // 发送告警通知 + } + } +} +``` + +**关键点:** +- ✅ 批量处理多家店铺 +- ✅ 提前7天刷新(避免过期) +- ✅ 异常处理和告警 + +--- + +## 4️⃣ **批量拉取商品信息的优化 - 验证分页查询** + +**任务目标:** 完善商品数据同步,确保分页正确处理 + +**当前问题:** 代码可能只拉取了第一页数据,未正确遍历所有页 + +**需要验证和优化:** + +```java +public void syncProducts(Long shopConfigId) { + ShopConfig shop = shopConfigMapper.selectById(shopConfigId); + + int page = 1; + int limit = 50; + boolean hasMore = true; + + while (hasMore) { // 👈 关键:循环直到没有更多数据 + // 调用店匠 API + String url = String.format( + "https://%s/openapi/2022-01/products?page=%d&limit=%d", + shop.getStoreDomain(), page, limit + ); + + ProductListResponse response = apiClient.get(url, shop.getAccessToken()); + + // 判断是否还有更多数据 + if (response.getProducts() == null || response.getProducts().isEmpty()) { + hasMore = false; // 👈 没有数据了,退出循环 + break; + } + + // 保存当前页的商品 + for (ProductDto product : response.getProducts()) { + saveProduct(shop.getTenantId(), shop.getStoreId(), product); + } + + page++; // 👈 下一页 + Thread.sleep(100); // 避免触发速率限制 + } +} +``` + +**验证要点:** +- ✅ 分页参数正确传递 +- ✅ 循环终止条件正确 +- ✅ 处理空页面情况 +- ✅ 速率限制控制 + +--- + +## 5️⃣ **批量拉取客户信息的优化 - 验证分页查询** + +**任务目标:** 与商品同步类似,完善客户数据同步 + +**实现逻辑:** +```java +public void syncCustomers(Long shopConfigId) { + // 与 syncProducts 类似,遍历所有分页 + String url = "https://{shop}/openapi/2022-01/customers?page={page}&limit=50"; + + // 循环拉取所有页 + // 保存到 shoplazza_customer 和 shoplazza_customer_address 表 +} +``` + +--- + +## 6️⃣ **批量拉取订单信息的优化 - 验证分页查询** + +**任务目标:** 完善订单数据同步 + +**实现逻辑:** +```java +public void syncOrders(Long shopConfigId) { + String url = "https://{shop}/openapi/2022-01/orders?page={page}&limit=50"; + + // 保存到 shoplazza_order 和 shoplazza_order_item 表 +} +``` + +--- + +## 7️⃣ **批量拉取店铺信息的实现 - 新增实现,需设计对应的数据库表** + +**任务目标:** 拉取店铺的详细配置信息 + +**API 调用:** +```bash +GET /openapi/2022-01/shop +``` + +**可能的响应字段:** +```json +{ + "id": "2286274", + "name": "47167113-1", + "domain": "47167113-1.myshoplaza.com", + "email": "shop@example.com", + "currency": "USD", + "timezone": "Asia/Shanghai", + "locale": "zh-CN", + "address": {...}, + "phone": "+86 123456789" +} +``` + +**需要设计的数据表:** +```sql +CREATE TABLE `shoplazza_shop_info` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `store_id` VARCHAR(64) NOT NULL, + `shop_name` VARCHAR(255), + `domain` VARCHAR(255), + `email` VARCHAR(255), + `currency` VARCHAR(16), + `timezone` VARCHAR(64), + `locale` VARCHAR(16), + `phone` VARCHAR(64), + `address` JSON, -- 存储完整地址信息 + `plan_name` VARCHAR(64), -- 套餐名称 + `created_at` DATETIME, + `updated_at` DATETIME, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_store_id` (`store_id`) +) COMMENT='店铺详细信息表'; +``` + +--- + +## 8️⃣ **注册店铺的 Webhook - 新增实现,需考虑安全验证** + +**任务目标:** 为每个店铺注册 Webhook,接收实时数据变更通知 + +**实现步骤:** + +### A. 注册 Webhook(后端主动调用) + +```java +@Service +public class WebhookService { + + private static final List WEBHOOK_TOPICS = Arrays.asList( + "products/create", "products/update", "products/delete", + "orders/create", "orders/updated", "customers/create" + ); + + public void registerWebhooks(Long shopConfigId) { + ShopConfig shop = shopConfigMapper.selectById(shopConfigId); + String webhookUrl = "https://your-domain.com/webhook/shoplazza/" + shop.getStoreId(); + + for (String topic : WEBHOOK_TOPICS) { + // 调用店匠 API 注册 + apiClient.post( + "https://" + shop.getStoreDomain() + "/openapi/2022-01/webhooks", + shop.getAccessToken(), + Map.of("address", webhookUrl, "topic", topic) + ); + } + } +} +``` + +### B. 接收 Webhook(店匠主动推送) + +```java +@RestController +@RequestMapping("/webhook/shoplazza") +public class WebhookController { + + @PostMapping("/{storeId}") + public ResponseEntity handleWebhook( + @PathVariable String storeId, + @RequestHeader("X-Shoplazza-Hmac-Sha256") String signature, // 👈 安全验证 + @RequestHeader("X-Shoplazza-Topic") String topic, + @RequestBody String payload) { + + // 1. 验证签名(安全验证) + if (!verifySignature(payload, signature, clientSecret)) { + return ResponseEntity.status(401).body("Invalid signature"); + } + + // 2. 异步处理事件 + webhookService.processAsync(storeId, topic, payload); + + // 3. 立即返回 200(店匠要求3秒内响应) + return ResponseEntity.ok("OK"); + } + + // HMAC-SHA256 签名验证 + private boolean verifySignature(String payload, String signature, String secret) { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); + byte[] hash = mac.doFinal(payload.getBytes()); + String computed = Base64.getEncoder().encodeToString(hash); + return computed.equals(signature); + } +} +``` + +**安全验证关键点:** +- ✅ 使用 HMAC-SHA256 验证签名 +- ✅ 签名密钥使用 APP 的 Client Secret +- ✅ 3秒内返回响应 +- ✅ 异步处理事件,避免超时 + +--- + + + + + + + + + + + + + + + + + + + -- libgit2 0.21.2