Commit 06e7908273c7d5a5cda8cec9830907945f0f8c56
1 parent
13377199
接口优化
Showing
8 changed files
with
1177 additions
and
0 deletions
Show diff stats
| ... | ... | @@ -0,0 +1,469 @@ |
| 1 | +<!-- b5a93a00-49d7-4266-8dbf-3d3f708334ed c9ba91cf-2b58-440d-86d1-35b805e5d3cf --> | |
| 2 | +# Configuration and Pipeline Separation Refactoring | |
| 3 | + | |
| 4 | +## Overview | |
| 5 | + | |
| 6 | +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. | |
| 7 | + | |
| 8 | +## Phase 1: Configuration File Cleanup | |
| 9 | + | |
| 10 | +### 1.1 Clean BASE Configuration | |
| 11 | + | |
| 12 | +**File**: [`config/schema/base/config.yaml`](config/schema/base/config.yaml) | |
| 13 | + | |
| 14 | +**Remove** (data pipeline concerns): | |
| 15 | + | |
| 16 | +- `mysql_config` section | |
| 17 | +- `main_table` field | |
| 18 | +- `sku_table` field | |
| 19 | +- `extension_table` field | |
| 20 | +- `source_table` in field definitions | |
| 21 | +- `source_column` in field definitions | |
| 22 | + | |
| 23 | +**Keep** (search configuration): | |
| 24 | + | |
| 25 | +- `customer_name` | |
| 26 | +- `es_index_name` | |
| 27 | +- `es_settings` | |
| 28 | +- `fields` (simplified, no source mapping) | |
| 29 | +- `indexes` (search domains) | |
| 30 | +- `query_config` | |
| 31 | +- `function_score` | |
| 32 | +- `rerank` | |
| 33 | +- `spu_config` | |
| 34 | +- `tenant_config` (as template) | |
| 35 | +- `default_facets` | |
| 36 | + | |
| 37 | +**Simplify field definitions**: | |
| 38 | + | |
| 39 | +```yaml | |
| 40 | +fields: | |
| 41 | + - name: "title" | |
| 42 | + type: "TEXT" | |
| 43 | + analyzer: "chinese_ecommerce" | |
| 44 | + boost: 3.0 | |
| 45 | + index: true | |
| 46 | + store: true | |
| 47 | + # NO source_table, NO source_column | |
| 48 | +``` | |
| 49 | + | |
| 50 | +### 1.2 Update Legacy Configuration | |
| 51 | + | |
| 52 | +**File**: [`config/schema/customer1_legacy/config.yaml`](config/schema/customer1_legacy/config.yaml) | |
| 53 | + | |
| 54 | +Apply same cleanup as BASE config, marking it as legacy in comments. | |
| 55 | + | |
| 56 | +## Phase 2: Transformer Architecture Refactoring | |
| 57 | + | |
| 58 | +### 2.1 Create Base Transformer Class | |
| 59 | + | |
| 60 | +**File**: [`indexer/base_transformer.py`](indexer/base_transformer.py) (NEW) | |
| 61 | + | |
| 62 | +Create abstract base class with shared logic: | |
| 63 | + | |
| 64 | +- `__init__` with config, encoders, cache | |
| 65 | +- `_convert_value()` - type conversion (shared) | |
| 66 | +- `_generate_text_embeddings()` - text embedding (shared) | |
| 67 | +- `_generate_image_embeddings()` - image embedding (shared) | |
| 68 | +- `_inject_tenant_id()` - tenant_id injection (shared) | |
| 69 | +- `@abstractmethod transform()` - to be implemented by subclasses | |
| 70 | + | |
| 71 | +### 2.2 Refactor DataTransformer | |
| 72 | + | |
| 73 | +**File**: [`indexer/data_transformer.py`](indexer/data_transformer.py) | |
| 74 | + | |
| 75 | +Changes: | |
| 76 | + | |
| 77 | +- Inherit from `BaseDataTransformer` | |
| 78 | +- Remove dependency on `source_table`, `source_column` from config | |
| 79 | +- Accept field mapping as parameter (from script) | |
| 80 | +- Implement `transform(df, field_mapping)` method | |
| 81 | + | |
| 82 | +### 2.3 Refactor SPUDataTransformer | |
| 83 | + | |
| 84 | +**File**: [`indexer/spu_data_transformer.py`](indexer/spu_data_transformer.py) | |
| 85 | + | |
| 86 | +Changes: | |
| 87 | + | |
| 88 | +- Inherit from `BaseDataTransformer` | |
| 89 | +- Remove dependency on config's table names | |
| 90 | +- Accept field mapping as parameter | |
| 91 | +- Implement `transform(spu_df, sku_df, spu_field_mapping, sku_field_mapping)` method | |
| 92 | + | |
| 93 | +### 2.4 Create Transformer Factory | |
| 94 | + | |
| 95 | +**File**: [`indexer/transformer_factory.py`](indexer/transformer_factory.py) (NEW) | |
| 96 | + | |
| 97 | +Factory to create appropriate transformer based on parameters: | |
| 98 | + | |
| 99 | +```python | |
| 100 | +class TransformerFactory: | |
| 101 | + @staticmethod | |
| 102 | + def create( | |
| 103 | + transformer_type: str, # 'sku' or 'spu' | |
| 104 | + config: CustomerConfig, | |
| 105 | + text_encoder=None, | |
| 106 | + image_encoder=None | |
| 107 | + ) -> BaseDataTransformer: | |
| 108 | + if transformer_type == 'spu': | |
| 109 | + return SPUDataTransformer(config, text_encoder, image_encoder) | |
| 110 | + elif transformer_type == 'sku': | |
| 111 | + return DataTransformer(config, text_encoder, image_encoder) | |
| 112 | + else: | |
| 113 | + raise ValueError(f"Unknown transformer type: {transformer_type}") | |
| 114 | +``` | |
| 115 | + | |
| 116 | +### 2.5 Update Package Exports | |
| 117 | + | |
| 118 | +**File**: [`indexer/__init__.py`](indexer/**init**.py) | |
| 119 | + | |
| 120 | +Export new structure: | |
| 121 | + | |
| 122 | +```python | |
| 123 | +from .base_transformer import BaseDataTransformer | |
| 124 | +from .data_transformer import DataTransformer | |
| 125 | +from .spu_data_transformer import SPUDataTransformer | |
| 126 | +from .transformer_factory import TransformerFactory | |
| 127 | + | |
| 128 | +__all__ = [ | |
| 129 | + 'BaseDataTransformer', | |
| 130 | + 'DataTransformer', | |
| 131 | + 'SPUDataTransformer', | |
| 132 | + 'TransformerFactory', # Recommended for new code | |
| 133 | + 'BulkIndexer', | |
| 134 | + 'IndexingPipeline', | |
| 135 | +] | |
| 136 | +``` | |
| 137 | + | |
| 138 | +## Phase 3: Script Refactoring | |
| 139 | + | |
| 140 | +### 3.1 Create Unified Ingestion Script | |
| 141 | + | |
| 142 | +**File**: [`scripts/ingest_universal.py`](scripts/ingest_universal.py) (NEW) | |
| 143 | + | |
| 144 | +Universal ingestion script with full parameter control: | |
| 145 | + | |
| 146 | +**Parameters**: | |
| 147 | + | |
| 148 | +```bash | |
| 149 | +# Search configuration (pure) | |
| 150 | +--config base # Which search config to use | |
| 151 | + | |
| 152 | +# Runtime parameters | |
| 153 | +--tenant-id shop_12345 # REQUIRED tenant identifier | |
| 154 | +--es-host http://localhost:9200 | |
| 155 | +--es-username elastic | |
| 156 | +--es-password xxx | |
| 157 | + | |
| 158 | +# Data source parameters (pipeline concern) | |
| 159 | +--data-source mysql # mysql, csv, api, etc. | |
| 160 | +--mysql-host 120.79.247.228 | |
| 161 | +--mysql-port 3316 | |
| 162 | +--mysql-database saas | |
| 163 | +--mysql-username saas | |
| 164 | +--mysql-password xxx | |
| 165 | + | |
| 166 | +# Transformer parameters (pipeline concern) | |
| 167 | +--transformer spu # spu or sku | |
| 168 | +--spu-table shoplazza_product_spu | |
| 169 | +--sku-table shoplazza_product_sku | |
| 170 | +--shop-id 1 # Filter by shop_id | |
| 171 | + | |
| 172 | +# Field mapping (optional, uses defaults if not provided) | |
| 173 | +--field-mapping mapping.json | |
| 174 | + | |
| 175 | +# Processing parameters | |
| 176 | +--batch-size 100 | |
| 177 | +--limit 1000 | |
| 178 | +--skip-embeddings | |
| 179 | +--recreate-index | |
| 180 | +``` | |
| 181 | + | |
| 182 | +**Logic**: | |
| 183 | + | |
| 184 | +1. Load search config (clean, no data source info) | |
| 185 | +2. Set tenant_id from parameter | |
| 186 | +3. Connect to data source based on `--data-source` parameter | |
| 187 | +4. Load data from tables specified by parameters | |
| 188 | +5. Create transformer based on `--transformer` parameter | |
| 189 | +6. Apply field mapping (default or custom) | |
| 190 | +7. Transform and index | |
| 191 | + | |
| 192 | +### 3.2 Update BASE Ingestion Script | |
| 193 | + | |
| 194 | +**File**: [`scripts/ingest_base.py`](scripts/ingest_base.py) | |
| 195 | + | |
| 196 | +Update to use script parameters instead of config values: | |
| 197 | + | |
| 198 | +- Remove dependency on `config.mysql_config` | |
| 199 | +- Remove dependency on `config.main_table`, `config.sku_table` | |
| 200 | +- Get all data source info from command-line arguments | |
| 201 | +- Use TransformerFactory | |
| 202 | + | |
| 203 | +### 3.3 Create Field Mapping Helper | |
| 204 | + | |
| 205 | +**File**: [`scripts/field_mapping_generator.py`](scripts/field_mapping_generator.py) (NEW) | |
| 206 | + | |
| 207 | +Helper script to generate default field mappings: | |
| 208 | + | |
| 209 | +```python | |
| 210 | +# Generate default mapping for Shoplazza SPU schema | |
| 211 | +python scripts/field_mapping_generator.py \ | |
| 212 | + --source shoplazza \ | |
| 213 | + --level spu \ | |
| 214 | + --output mappings/shoplazza_spu.json | |
| 215 | +``` | |
| 216 | + | |
| 217 | +Output example: | |
| 218 | + | |
| 219 | +```json | |
| 220 | +{ | |
| 221 | + "spu_fields": { | |
| 222 | + "id": "id", | |
| 223 | + "title": "title", | |
| 224 | + "description": "description", | |
| 225 | + ... | |
| 226 | + }, | |
| 227 | + "sku_fields": { | |
| 228 | + "id": "id", | |
| 229 | + "price": "price", | |
| 230 | + "sku": "sku", | |
| 231 | + ... | |
| 232 | + } | |
| 233 | +} | |
| 234 | +``` | |
| 235 | + | |
| 236 | +## Phase 4: Configuration Loader Updates | |
| 237 | + | |
| 238 | +### 4.1 Simplify ConfigLoader | |
| 239 | + | |
| 240 | +**File**: [`config/config_loader.py`](config/config_loader.py) | |
| 241 | + | |
| 242 | +Changes: | |
| 243 | + | |
| 244 | +- Remove parsing of `mysql_config` | |
| 245 | +- Remove parsing of `main_table`, `sku_table`, `extension_table` | |
| 246 | +- Remove validation of source_table/source_column in fields | |
| 247 | +- Simplify field parsing (no source mapping) | |
| 248 | +- Keep validation of ES/search related config | |
| 249 | + | |
| 250 | +### 4.2 Update CustomerConfig Model | |
| 251 | + | |
| 252 | +**File**: [`config/__init__.py`](config/**init**.py) or wherever CustomerConfig is defined | |
| 253 | + | |
| 254 | +Remove attributes: | |
| 255 | + | |
| 256 | +- `mysql_config` | |
| 257 | +- `main_table` | |
| 258 | +- `sku_table` | |
| 259 | +- `extension_table` | |
| 260 | + | |
| 261 | +Add attributes: | |
| 262 | + | |
| 263 | +- `tenant_id` (runtime, default None) | |
| 264 | + | |
| 265 | +Simplify FieldConfig: | |
| 266 | + | |
| 267 | +- Remove `source_table` | |
| 268 | +- Remove `source_column` | |
| 269 | + | |
| 270 | +## Phase 5: Documentation Updates | |
| 271 | + | |
| 272 | +### 5.1 Create Pipeline Guide | |
| 273 | + | |
| 274 | +**File**: [`docs/DATA_PIPELINE_GUIDE.md`](docs/DATA_PIPELINE_GUIDE.md) (NEW) | |
| 275 | + | |
| 276 | +Document: | |
| 277 | + | |
| 278 | +- Separation of concerns (config vs pipeline) | |
| 279 | +- How to use `ingest_universal.py` | |
| 280 | +- Default field mappings for common sources | |
| 281 | +- Custom field mapping examples | |
| 282 | +- Transformer selection guide | |
| 283 | + | |
| 284 | +### 5.2 Update BASE Config Guide | |
| 285 | + | |
| 286 | +**File**: [`docs/BASE_CONFIG_GUIDE.md`](docs/BASE_CONFIG_GUIDE.md) | |
| 287 | + | |
| 288 | +Update to reflect: | |
| 289 | + | |
| 290 | +- Config only contains search settings | |
| 291 | +- No data source configuration | |
| 292 | +- How tenant_id is injected at runtime | |
| 293 | +- Examples of using same config with different data sources | |
| 294 | + | |
| 295 | +### 5.3 Update API Documentation | |
| 296 | + | |
| 297 | +**File**: [`API_DOCUMENTATION.md`](API_DOCUMENTATION.md) | |
| 298 | + | |
| 299 | +No changes needed (API layer doesn't know about data pipeline). | |
| 300 | + | |
| 301 | +### 5.4 Update Design Documentation | |
| 302 | + | |
| 303 | +**File**: [`设计文档.md`](设计文档.md) | |
| 304 | + | |
| 305 | +Add section on configuration architecture: | |
| 306 | + | |
| 307 | +- Clear separation between search config and pipeline | |
| 308 | +- Benefits of this approach | |
| 309 | +- How to extend for new data sources | |
| 310 | + | |
| 311 | +## Phase 6: Create Default Field Mappings | |
| 312 | + | |
| 313 | +### 6.1 Shoplazza SPU Mapping | |
| 314 | + | |
| 315 | +**File**: [`mappings/shoplazza_spu.json`](mappings/shoplazza_spu.json) (NEW) | |
| 316 | + | |
| 317 | +Default field mapping for Shoplazza SPU/SKU tables to BASE config fields. | |
| 318 | + | |
| 319 | +### 6.2 Shoplazza SKU Mapping (Legacy) | |
| 320 | + | |
| 321 | +**File**: [`mappings/shoplazza_sku_legacy.json`](mappings/shoplazza_sku_legacy.json) (NEW) | |
| 322 | + | |
| 323 | +Default field mapping for legacy SKU-level indexing. | |
| 324 | + | |
| 325 | +### 6.3 CSV Template Mapping | |
| 326 | + | |
| 327 | +**File**: [`mappings/csv_template.json`](mappings/csv_template.json) (NEW) | |
| 328 | + | |
| 329 | +Example mapping for CSV data sources. | |
| 330 | + | |
| 331 | +## Phase 7: Testing & Validation | |
| 332 | + | |
| 333 | +### 7.1 Test Script with Different Sources | |
| 334 | + | |
| 335 | +Test `ingest_universal.py` with: | |
| 336 | + | |
| 337 | +1. MySQL Shoplazza tables (SPU level) | |
| 338 | +2. MySQL Shoplazza tables (SKU level, legacy) | |
| 339 | +3. CSV files (if time permits) | |
| 340 | + | |
| 341 | +### 7.2 Verify Configuration Portability | |
| 342 | + | |
| 343 | +Test same BASE config with: | |
| 344 | + | |
| 345 | +- Different data sources | |
| 346 | +- Different field mappings | |
| 347 | +- Different transformers | |
| 348 | + | |
| 349 | +### 7.3 Update Test Scripts | |
| 350 | + | |
| 351 | +**File**: [`scripts/test_base.sh`](scripts/test_base.sh) | |
| 352 | + | |
| 353 | +Update to use new script parameters. | |
| 354 | + | |
| 355 | +## Phase 8: Migration & Cleanup | |
| 356 | + | |
| 357 | +### 8.1 Create Migration Guide | |
| 358 | + | |
| 359 | +**File**: [`docs/CONFIG_MIGRATION_GUIDE.md`](docs/CONFIG_MIGRATION_GUIDE.md) (NEW) | |
| 360 | + | |
| 361 | +Guide for migrating from old config format to new: | |
| 362 | + | |
| 363 | +- What changed | |
| 364 | +- How to update existing configs | |
| 365 | +- How to update ingestion scripts | |
| 366 | +- Breaking changes | |
| 367 | + | |
| 368 | +### 8.2 Update Example Configs | |
| 369 | + | |
| 370 | +Update all example configurations to new format. | |
| 371 | + | |
| 372 | +### 8.3 Mark Old Scripts as Deprecated | |
| 373 | + | |
| 374 | +Add deprecation warnings to scripts that still use old config format. | |
| 375 | + | |
| 376 | +## Key Design Principles | |
| 377 | + | |
| 378 | +### 1. Separation of Concerns | |
| 379 | + | |
| 380 | +**Search Configuration** (customer-facing): | |
| 381 | + | |
| 382 | +- What fields exist in ES | |
| 383 | +- How fields are analyzed/indexed | |
| 384 | +- Search strategies and ranking | |
| 385 | +- Facets and aggregations | |
| 386 | +- Query processing rules | |
| 387 | + | |
| 388 | +**Data Pipeline** (internal): | |
| 389 | + | |
| 390 | +- Where data comes from | |
| 391 | +- How to connect to data sources | |
| 392 | +- Which tables/files to read | |
| 393 | +- How to transform data | |
| 394 | +- Field mapping logic | |
| 395 | + | |
| 396 | +### 2. Configuration Portability | |
| 397 | + | |
| 398 | +Same search config can be used with: | |
| 399 | + | |
| 400 | +- Different data sources (MySQL, CSV, API) | |
| 401 | +- Different schemas (with appropriate mapping) | |
| 402 | +- Different transformation strategies | |
| 403 | + | |
| 404 | +### 3. Flexibility | |
| 405 | + | |
| 406 | +Pipeline decisions (transformer, data source, field mapping) made at runtime, not in config. | |
| 407 | + | |
| 408 | +## Migration Path | |
| 409 | + | |
| 410 | +### For Existing Users | |
| 411 | + | |
| 412 | +1. Update config files (remove data source settings) | |
| 413 | +2. Update ingestion commands (add new parameters) | |
| 414 | +3. Optionally create field mapping files for convenience | |
| 415 | + | |
| 416 | +### For New Users | |
| 417 | + | |
| 418 | +1. Copy BASE config (already clean) | |
| 419 | +2. Run `ingest_universal.py` with appropriate parameters | |
| 420 | +3. Provide custom field mapping if needed | |
| 421 | + | |
| 422 | +## Success Criteria | |
| 423 | + | |
| 424 | +- [ ] BASE config contains ZERO data source information | |
| 425 | +- [ ] Same config works with MySQL and CSV sources | |
| 426 | +- [ ] Pipeline fully controlled by script parameters | |
| 427 | +- [ ] Transformers work with external field mapping | |
| 428 | +- [ ] Documentation clearly separates concerns | |
| 429 | +- [ ] Tests validate portability | |
| 430 | +- [ ] Migration guide provided | |
| 431 | + | |
| 432 | +## Estimated Effort | |
| 433 | + | |
| 434 | +- Configuration cleanup: 2 hours | |
| 435 | +- Transformer refactoring: 4-5 hours | |
| 436 | +- Script refactoring: 3-4 hours | |
| 437 | +- Config loader updates: 2 hours | |
| 438 | +- Documentation: 2-3 hours | |
| 439 | +- Testing & validation: 2-3 hours | |
| 440 | +- **Total: 15-19 hours** | |
| 441 | + | |
| 442 | +## Benefits | |
| 443 | + | |
| 444 | +✅ **Clean separation of concerns** | |
| 445 | + | |
| 446 | +✅ **Configuration reusability across data sources** | |
| 447 | + | |
| 448 | +✅ **Customer doesn't need to understand ETL** | |
| 449 | + | |
| 450 | +✅ **Easier to add new data sources** | |
| 451 | + | |
| 452 | +✅ **More flexible pipeline control** | |
| 453 | + | |
| 454 | +✅ **Reduced configuration complexity** | |
| 455 | + | |
| 456 | +### To-dos | |
| 457 | + | |
| 458 | +- [ ] Clean BASE and legacy configs: remove mysql_config, table names, source_table/source_column from fields | |
| 459 | +- [ ] Create BaseDataTransformer abstract class with shared logic (type conversion, embeddings, tenant_id) | |
| 460 | +- [ ] Refactor DataTransformer and SPUDataTransformer to inherit from base, accept field mapping as parameter | |
| 461 | +- [ ] Create TransformerFactory for creating transformers based on type parameter | |
| 462 | +- [ ] Create ingest_universal.py with full parameter control for data source, transformer, field mapping | |
| 463 | +- [ ] Update scripts/ingest_base.py to use parameters instead of config for data source | |
| 464 | +- [ ] Create field_mapping_generator.py and default mapping files (shoplazza_spu.json, etc.) | |
| 465 | +- [ ] Simplify ConfigLoader to only parse search config, remove data source parsing | |
| 466 | +- [ ] Create DATA_PIPELINE_GUIDE.md documenting pipeline approach and config separation | |
| 467 | +- [ ] Update BASE_CONFIG_GUIDE.md to reflect config-only-search-settings approach | |
| 468 | +- [ ] Create CONFIG_MIGRATION_GUIDE.md for migrating from old to new config format | |
| 469 | +- [ ] Test same config with different data sources and validate portability | |
| 0 | 470 | \ No newline at end of file | ... | ... |
.cursor/plans/spu-index-b5a93a00.plan.md renamed to .cursor/plans/所有tenant按同一份所有_返回接口优化.md
SHOPLAZZA_INTEGRATION_GUIDE.md renamed to docs/店匠相关资料/SHOPLAZZA_INTEGRATION_GUIDE.md
| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | +### 13.1 官方文档 | |
| 2 | + | |
| 3 | +- [店匠开发者文档](https://www.shoplazza.dev/reference/overview-29) | |
| 4 | +- [店匠 OAuth 文档](https://www.shoplazza.dev/v2024.07/reference/authentication) | |
| 5 | +- [店匠 API 参考](https://www.shoplazza.dev/v2024.07/reference/overview) | |
| 6 | +- [店匠 Webhook 文档](https://www.shoplazza.dev/v2024.07/reference/webhooks) | |
| 7 | + | |
| 8 | +### 13.2 技术栈文档 | |
| 9 | + | |
| 10 | +- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749) | |
| 11 | +- [Elasticsearch 官方文档](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) | |
| 12 | +- [Liquid 模板语言](https://shopify.github.io/liquid/) | |
| 13 | +- [FastAPI 文档](https://fastapi.tiangolo.com/) | ... | ... |
| ... | ... | @@ -0,0 +1,261 @@ |
| 1 | +非常好的问题!让我详细解释一下搜索应用的调用链路和 OAuth 的关系。 | |
| 2 | + | |
| 3 | +## 调用链路说明 | |
| 4 | + | |
| 5 | +### 1. **OAuth 认证的作用** | |
| 6 | + | |
| 7 | +OAuth **不是用于前端搜索调用**的,它主要用于: | |
| 8 | + | |
| 9 | +```mermaid | |
| 10 | +graph LR | |
| 11 | + A[商家安装APP] --> B[OAuth授权] | |
| 12 | + B --> C[获取Access Token] | |
| 13 | + C --> D[后端拉取商品数据] | |
| 14 | + D --> E[建立ES索引] | |
| 15 | + E --> F[搜索服务就绪] | |
| 16 | +``` | |
| 17 | + | |
| 18 | +**OAuth Token 的用途:** | |
| 19 | +- ✅ 你的后端调用店匠 Admin API(拉取商品、订单、客户数据) | |
| 20 | +- ✅ 注册 Webhook(接收数据变更通知) | |
| 21 | +- ❌ **不用于**前端买家搜索时的调用 | |
| 22 | + | |
| 23 | +### 2. **前端搜索的实际调用链路** | |
| 24 | + | |
| 25 | +当买家在店铺搜索商品时: | |
| 26 | + | |
| 27 | +``` | |
| 28 | +买家浏览器 → 搜索框组件(Liquid/JS) → 你的搜索API → Elasticsearch → 返回结果 | |
| 29 | +``` | |
| 30 | + | |
| 31 | +**关键点:** | |
| 32 | +- 前端 JavaScript **直接调用**你的公开搜索 API | |
| 33 | +- 不需要 OAuth Token | |
| 34 | +- 需要传递 `store_id` 参数来标识是哪个店铺 | |
| 35 | + | |
| 36 | +### 3. **搜索接口的两种方案** | |
| 37 | + | |
| 38 | +[2 tools called] | |
| 39 | + | |
| 40 | +## 详细解答 | |
| 41 | + | |
| 42 | +### 📍 **方案 A:前端直接调用(推荐用于公开搜索)** | |
| 43 | + | |
| 44 | +**流程:** | |
| 45 | + | |
| 46 | +```javascript | |
| 47 | +// 在店铺前端页面(买家浏览器中) | |
| 48 | +const response = await fetch('https://your-domain.com/api/search/products', { | |
| 49 | + method: 'POST', | |
| 50 | + headers: { | |
| 51 | + 'Content-Type': 'application/json' | |
| 52 | + }, | |
| 53 | + body: JSON.stringify({ | |
| 54 | + query: "蓝牙耳机", | |
| 55 | + tenant: "tenant_47167113-1", // 店铺标识 | |
| 56 | + size: 24, | |
| 57 | + filters: {}, | |
| 58 | + facets: ['product_type', 'vendor'] | |
| 59 | + }) | |
| 60 | +}); | |
| 61 | +``` | |
| 62 | + | |
| 63 | +**你的搜索 API 需要:** | |
| 64 | + | |
| 65 | +1. **允许跨域访问(CORS)**: | |
| 66 | +```python | |
| 67 | +# Python FastAPI 示例 | |
| 68 | +from fastapi.middleware.cors import CORSMiddleware | |
| 69 | + | |
| 70 | +app.add_middleware( | |
| 71 | + CORSMiddleware, | |
| 72 | + allow_origins=["*"], # 或指定店匠域名白名单 | |
| 73 | + allow_credentials=True, | |
| 74 | + allow_methods=["POST"], | |
| 75 | + allow_headers=["*"], | |
| 76 | +) | |
| 77 | +``` | |
| 78 | + | |
| 79 | +2. **根据 store_id 隔离数据**: | |
| 80 | +```python | |
| 81 | +@app.post("/api/search/products") | |
| 82 | +async def search(request: SearchRequest): | |
| 83 | + # 从 tenant 参数提取 tenant_id | |
| 84 | + tenant_id = extract_tenant_id(request.tenant) | |
| 85 | + | |
| 86 | + # 使用租户专属索引 | |
| 87 | + index_name = f"shoplazza_products_{tenant_id}" | |
| 88 | + | |
| 89 | + # 执行搜索 | |
| 90 | + results = es_client.search(index=index_name, body=query) | |
| 91 | + return results | |
| 92 | +``` | |
| 93 | + | |
| 94 | +3. **不需要 OAuth Token 认证**(因为是公开查询) | |
| 95 | + | |
| 96 | +--- | |
| 97 | + | |
| 98 | +### 📍 **方案 B:通过 Java 后端中转(更安全)** | |
| 99 | + | |
| 100 | +**流程:** | |
| 101 | + | |
| 102 | +``` | |
| 103 | +买家浏览器 → Java后端(/api/search/products?storeId=xxx) → Python搜索服务 → ES | |
| 104 | +``` | |
| 105 | + | |
| 106 | +**Java 后端代码:** | |
| 107 | + | |
| 108 | +```java | |
| 109 | +@RestController | |
| 110 | +@RequestMapping("/api/search") | |
| 111 | +public class SearchController { | |
| 112 | + | |
| 113 | + @PostMapping("/products") | |
| 114 | + public ResponseEntity<SearchResponse> search( | |
| 115 | + @RequestParam String storeId, // 从URL参数获取店铺ID | |
| 116 | + @RequestBody SearchRequest request) { | |
| 117 | + | |
| 118 | + // 1. 验证店铺ID(可选:检查域名白名单) | |
| 119 | + ShopConfig shop = shopConfigMapper.selectByStoreId(storeId); | |
| 120 | + if (shop == null) { | |
| 121 | + return ResponseEntity.notFound().build(); | |
| 122 | + } | |
| 123 | + | |
| 124 | + // 2. 添加租户隔离参数 | |
| 125 | + request.setTenant("tenant_" + shop.getTenantId()); | |
| 126 | + | |
| 127 | + // 3. 调用 Python 搜索服务 | |
| 128 | + SearchResponse response = restTemplate.postForObject( | |
| 129 | + "http://localhost:6002/search/", | |
| 130 | + request, | |
| 131 | + SearchResponse.class | |
| 132 | + ); | |
| 133 | + | |
| 134 | + // 4. 记录搜索日志 | |
| 135 | + searchLogService.logSearch(shop.getId(), request.getQuery(), response.getTotal()); | |
| 136 | + | |
| 137 | + return ResponseEntity.ok(response); | |
| 138 | + } | |
| 139 | +} | |
| 140 | +``` | |
| 141 | + | |
| 142 | +**前端调用(带 store_id):** | |
| 143 | + | |
| 144 | +```javascript | |
| 145 | +const response = await fetch( | |
| 146 | + `https://your-domain.com/api/search/products?storeId=${config.storeId}`, | |
| 147 | + { | |
| 148 | + method: 'POST', | |
| 149 | + headers: { 'Content-Type': 'application/json' }, | |
| 150 | + body: JSON.stringify({ | |
| 151 | + query: "蓝牙耳机", | |
| 152 | + size: 24, | |
| 153 | + filters: {}, | |
| 154 | + facets: ['product_type', 'vendor'] | |
| 155 | + }) | |
| 156 | + } | |
| 157 | +); | |
| 158 | +``` | |
| 159 | + | |
| 160 | +--- | |
| 161 | + | |
| 162 | +## 🔐 OAuth 认证在整个系统中的位置 | |
| 163 | + | |
| 164 | +```mermaid | |
| 165 | +graph TB | |
| 166 | + subgraph "1. 商家安装阶段(使用OAuth)" | |
| 167 | + A[商家安装APP] --> B[OAuth授权] | |
| 168 | + B --> C[获取Access Token] | |
| 169 | + C --> D[存储Token到数据库] | |
| 170 | + end | |
| 171 | + | |
| 172 | + subgraph "2. 数据准备阶段(使用OAuth Token)" | |
| 173 | + D --> E[定时任务启动] | |
| 174 | + E --> F[使用Token调用店匠API] | |
| 175 | + F --> G[拉取商品/订单数据] | |
| 176 | + G --> H[建立ES索引] | |
| 177 | + end | |
| 178 | + | |
| 179 | + subgraph "3. 买家搜索阶段(不需要OAuth)" | |
| 180 | + I[买家访问店铺] --> J[输入搜索词] | |
| 181 | + J --> K[前端JS直接调用搜索API] | |
| 182 | + K --> L[搜索ES索引] | |
| 183 | + L --> M[返回结果] | |
| 184 | + end | |
| 185 | + | |
| 186 | + H -.索引建立后.-> L | |
| 187 | +``` | |
| 188 | + | |
| 189 | +**关键理解:** | |
| 190 | +- **OAuth Token** = 你的后端 ↔ 店匠 Admin API(拉数据用) | |
| 191 | +- **前端搜索** = 买家浏览器 ↔ 你的搜索 API(不需要 OAuth) | |
| 192 | + | |
| 193 | +--- | |
| 194 | + | |
| 195 | +## ✅ 你需要做的事情 | |
| 196 | + | |
| 197 | +### 1. **搜索 API 设计** | |
| 198 | + | |
| 199 | +当前你的 Python 搜索服务应该已经支持: | |
| 200 | + | |
| 201 | +```python | |
| 202 | +POST http://your-domain:6002/search/ | |
| 203 | +Content-Type: application/json | |
| 204 | + | |
| 205 | +{ | |
| 206 | + "query": "蓝牙耳机", | |
| 207 | + "tenant": "tenant_1", // 重要!用于隔离不同店铺 | |
| 208 | + "size": 20, | |
| 209 | + "filters": {}, | |
| 210 | + "facets": ["product_type", "vendor"] | |
| 211 | +} | |
| 212 | +``` | |
| 213 | + | |
| 214 | +### 2. **CORS 配置**(如果前端直接调用) | |
| 215 | + | |
| 216 | +在 Python FastAPI 服务中添加: | |
| 217 | + | |
| 218 | +```python | |
| 219 | +from fastapi.middleware.cors import CORSMiddleware | |
| 220 | + | |
| 221 | +app.add_middleware( | |
| 222 | + CORSMiddleware, | |
| 223 | + allow_origins=[ | |
| 224 | + "https://*.myshoplaza.com", # 店匠域名 | |
| 225 | + "https://your-domain.com" | |
| 226 | + ], | |
| 227 | + allow_methods=["POST", "GET"], | |
| 228 | + allow_headers=["*"], | |
| 229 | +) | |
| 230 | +``` | |
| 231 | + | |
| 232 | +### 3. **店铺标识传递** | |
| 233 | + | |
| 234 | +在前端 Liquid 模板中获取店铺域名: | |
| 235 | + | |
| 236 | +```liquid | |
| 237 | +<script> | |
| 238 | +window.AI_SEARCH_CONFIG = { | |
| 239 | + storeId: "{{ shop.domain }}", // 店匠自动注入 | |
| 240 | + apiEndpoint: "https://your-domain.com/api/search/products" | |
| 241 | +}; | |
| 242 | +</script> | |
| 243 | +``` | |
| 244 | + | |
| 245 | +### 4. **安全性考虑** | |
| 246 | + | |
| 247 | +- ✅ 租户隔离:根据 `tenant` 参数查询不同索引 | |
| 248 | +- ✅ 域名白名单:只允许店匠域名调用 | |
| 249 | +- ✅ 速率限制:防止恶意请求 | |
| 250 | +- ❌ **不需要**在每次搜索时验证 OAuth Token | |
| 251 | + | |
| 252 | +--- | |
| 253 | + | |
| 254 | +## 🎯 总结 | |
| 255 | + | |
| 256 | +1. **OAuth 只用于后台数据同步**,不用于前端搜索 | |
| 257 | +2. **前端搜索直接调用你的公开 API**,通过 `storeId` 参数隔离数据 | |
| 258 | +3. **你的搜索接口可以是无认证的**,但必须做好租户隔离 | |
| 259 | +4. **推荐方案**:前端 → 你的 Java 后端 → Python 搜索服务(便于日志统计和安全控制) | |
| 260 | + | |
| 261 | +有其他疑问吗? | |
| 0 | 262 | \ No newline at end of file | ... | ... |
| ... | ... | @@ -0,0 +1,426 @@ |
| 1 | +让我详细解释这些任务,并查看相关的代码实现: | |
| 2 | + | |
| 3 | +[3 tools called] | |
| 4 | + | |
| 5 | +## 📋 详细任务解释 | |
| 6 | + | |
| 7 | +让我逐一解释这些开发任务: | |
| 8 | + | |
| 9 | +--- | |
| 10 | + | |
| 11 | +## 1️⃣ **OAuth 的迁移 - 参考 Go 的实现** | |
| 12 | + | |
| 13 | +**任务目标:** 将现有的 Go 语言 OAuth 实现迁移到 Java 项目中 | |
| 14 | + | |
| 15 | +**具体工作:** | |
| 16 | +```java | |
| 17 | +// 需要实现的 Java 端点: | |
| 18 | +GET /oauth/install?shop={shop_domain} // 处理APP安装请求 | |
| 19 | +GET /oauth/callback?code=xxx&shop=xxx // 处理OAuth授权回调 | |
| 20 | +POST /partner/oauth/token // 换取/刷新 Access Token | |
| 21 | +``` | |
| 22 | + | |
| 23 | +**参考的 Go 代码功能:** | |
| 24 | +- 生成授权 URL 并重定向 | |
| 25 | +- 处理授权回调 | |
| 26 | +- 用 code 换取 Access Token | |
| 27 | +- 解析 Token 响应并存储 | |
| 28 | + | |
| 29 | +--- | |
| 30 | + | |
| 31 | +## 2️⃣ **AccessToken 的存储 - 存储到 shoplazza_shop_config** | |
| 32 | + | |
| 33 | +**任务目标:** OAuth 成功后,将 Token 信息保存到数据库 | |
| 34 | + | |
| 35 | +**示意图:** | |
| 36 | +``` | |
| 37 | +店匠平台 搜索SaaS平台 | |
| 38 | +----------- ---------------- | |
| 39 | +[应用市场] | |
| 40 | + ↓ | |
| 41 | +[商家安装APP] --------→ OAuth授权流程 | |
| 42 | + ↓ ↓ | |
| 43 | +[商家授权成功] --------→ 【第2项】创建租户+存储Token | |
| 44 | + ↓ | |
| 45 | + system_tenant (新建) | |
| 46 | + shoplazza_shop_config (新建) | |
| 47 | + 存储 AccessToken 和 RefreshToken | |
| 48 | + ↓ | |
| 49 | + 【第3项】定时刷新Token | |
| 50 | +``` | |
| 51 | + | |
| 52 | + Token 的获取和使用流程 | |
| 53 | + | |
| 54 | +```mermaid | |
| 55 | +sequenceDiagram | |
| 56 | + participant 商家 | |
| 57 | + participant 店匠 | |
| 58 | + participant 你的后端 | |
| 59 | + participant 数据库 | |
| 60 | + | |
| 61 | + Note over 商家,你的后端: 1. OAuth 授权阶段 | |
| 62 | + 商家->>店匠: 安装 APP | |
| 63 | + 店匠->>你的后端: 跳转授权 | |
| 64 | + 商家->>店匠: 同意授权 | |
| 65 | + 店匠->>你的后端: 回调带 code | |
| 66 | + 你的后端->>店匠: 用 code 换 Token | |
| 67 | + 店匠->>你的后端: 返回 Access Token | |
| 68 | + 你的后端->>数据库: 存储到 shoplazza_shop_config | |
| 69 | + | |
| 70 | + Note over 你的后端,数据库: 2. 注册 Webhook 阶段 | |
| 71 | + 你的后端->>数据库: 读取 Access Token | |
| 72 | + 你的后端->>店匠: 注册 Webhook (带 Access Token) | |
| 73 | + 店匠->>你的后端: Webhook 注册成功 | |
| 74 | +``` | |
| 75 | + | |
| 76 | +**核心逻辑:** | |
| 77 | +```java | |
| 78 | +@Transactional | |
| 79 | +public void handleOAuthCallback(TokenResponse tokenResponse) { | |
| 80 | + // 1. 检查租户是否存在,不存在则创建 | |
| 81 | + Tenant tenant = tenantMapper.selectByStoreId(storeId); | |
| 82 | + if (tenant == null) { | |
| 83 | + tenant = new Tenant(); | |
| 84 | + tenant.setName(storeName); | |
| 85 | + tenantMapper.insert(tenant); // 👈 创建新租户 | |
| 86 | + } | |
| 87 | + | |
| 88 | + // 2. 创建或更新店铺配置 | |
| 89 | + ShopConfig shop = shopConfigMapper.selectByStoreId(storeId); | |
| 90 | + if (shop == null) { | |
| 91 | + shop = new ShopConfig(); | |
| 92 | + shop.setTenantId(tenant.getId()); | |
| 93 | + shop.setStoreId(storeId); | |
| 94 | + shop.setStoreName(storeName); | |
| 95 | + } | |
| 96 | + | |
| 97 | + // 3. 保存 Token 信息 | |
| 98 | + shop.setAccessToken(tokenResponse.getAccessToken()); // 👈 存储 | |
| 99 | + shop.setRefreshToken(tokenResponse.getRefreshToken()); // 👈 存储 | |
| 100 | + shop.setTokenExpiresAt(tokenResponse.getExpiresAt()); // 👈 存储 | |
| 101 | + shop.setLocale(tokenResponse.getLocale()); | |
| 102 | + shop.setStatus("active"); | |
| 103 | + | |
| 104 | + shopConfigMapper.insertOrUpdate(shop); | |
| 105 | +} | |
| 106 | +``` | |
| 107 | + | |
| 108 | +**数据表:** `shoplazza_shop_config`(已设计在文档第4章) | |
| 109 | + | |
| 110 | +### 📊 token数据库表关系 | |
| 111 | + | |
| 112 | +```sql | |
| 113 | +-- shoplazza_shop_config 表中存储的数据 | |
| 114 | ++----------+----------------+----------------------------------------+ | |
| 115 | +| store_id | store_name | access_token | | |
| 116 | ++----------+----------------+----------------------------------------+ | |
| 117 | +| 2286274 | 47167113-1 | V2WDYgkTvrN68QCESZ9eHb3EjpR6EB... | 👈 OAuth时保存 | |
| 118 | ++----------+----------------+----------------------------------------+ | |
| 119 | + ↓ | |
| 120 | + 注册 Webhook 时读取使用 | |
| 121 | +``` | |
| 122 | + | |
| 123 | +### 🔐 Token 的两种用途 | |
| 124 | + | |
| 125 | +**这个 Access Token 在你的系统中有两大用途:** | |
| 126 | + | |
| 127 | +1. **拉取数据** - 调用店匠 Admin API | |
| 128 | + - 拉取商品:`GET /openapi/2022-01/products` | |
| 129 | + - 拉取订单:`GET /openapi/2022-01/orders` | |
| 130 | + - 拉取客户:`GET /openapi/2022-01/customers` | |
| 131 | + | |
| 132 | +2. **注册 Webhook** - 让店匠主动推送数据变更 | |
| 133 | + - 注册:`POST /openapi/2022-01/webhooks`(需要 Token) | |
| 134 | + - 接收:店匠推送到你的 `/webhook/shoplazza/{storeId}` 端点(不需要 Token) | |
| 135 | + | |
| 136 | +### ⚠️ 注意事项 | |
| 137 | + | |
| 138 | +```java | |
| 139 | +// 注册 Webhook 前,确保 Token 有效 | |
| 140 | +public void registerWebhooks(Long shopConfigId) { | |
| 141 | + ShopConfig shop = shopConfigMapper.selectById(shopConfigId); | |
| 142 | + | |
| 143 | + // 检查 Token 是否过期 | |
| 144 | + if (shop.getTokenExpiresAt().before(new Date())) { | |
| 145 | + // Token 已过期,先刷新 | |
| 146 | + tokenService.refreshToken(shop); | |
| 147 | + shop = shopConfigMapper.selectById(shopConfigId); // 重新读取 | |
| 148 | + } | |
| 149 | + | |
| 150 | + // 使用有效的 Token 注册 Webhook | |
| 151 | + String accessToken = shop.getAccessToken(); | |
| 152 | + // ... 注册逻辑 | |
| 153 | +} | |
| 154 | +``` | |
| 155 | + | |
| 156 | +--- | |
| 157 | + | |
| 158 | +## 3️⃣ **RefreshToken 的实现 - 基于定时任务,需考虑对多家店铺的处理** | |
| 159 | + | |
| 160 | +**任务目标:** 自动刷新即将过期的 Access Token | |
| 161 | + | |
| 162 | +**实现方式:** | |
| 163 | + | |
| 164 | +```java | |
| 165 | +@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行 | |
| 166 | +public void refreshExpiringTokens() { | |
| 167 | + // 1. 查询7天内过期的所有店铺 | |
| 168 | + DateTime sevenDaysLater = DateTime.now().plusDays(7); | |
| 169 | + List<ShopConfig> shops = shopConfigMapper.selectExpiringTokens(sevenDaysLater); | |
| 170 | + | |
| 171 | + // 2. 遍历每个店铺,刷新 Token | |
| 172 | + for (ShopConfig shop : shops) { | |
| 173 | + try { | |
| 174 | + TokenResponse newToken = oauthClient.refreshToken( | |
| 175 | + shop.getRefreshToken(), | |
| 176 | + clientId, | |
| 177 | + clientSecret | |
| 178 | + ); | |
| 179 | + | |
| 180 | + // 3. 更新数据库中的 Token | |
| 181 | + shop.setAccessToken(newToken.getAccessToken()); | |
| 182 | + shop.setRefreshToken(newToken.getRefreshToken()); | |
| 183 | + shop.setTokenExpiresAt(newToken.getExpiresAt()); | |
| 184 | + shopConfigMapper.updateById(shop); | |
| 185 | + | |
| 186 | + log.info("Token refreshed for shop: {}", shop.getStoreName()); | |
| 187 | + } catch (Exception e) { | |
| 188 | + log.error("Failed to refresh token for shop: {}", shop.getStoreName(), e); | |
| 189 | + // 发送告警通知 | |
| 190 | + } | |
| 191 | + } | |
| 192 | +} | |
| 193 | +``` | |
| 194 | + | |
| 195 | +**关键点:** | |
| 196 | +- ✅ 批量处理多家店铺 | |
| 197 | +- ✅ 提前7天刷新(避免过期) | |
| 198 | +- ✅ 异常处理和告警 | |
| 199 | + | |
| 200 | +--- | |
| 201 | + | |
| 202 | +## 4️⃣ **批量拉取商品信息的优化 - 验证分页查询** | |
| 203 | + | |
| 204 | +**任务目标:** 完善商品数据同步,确保分页正确处理 | |
| 205 | + | |
| 206 | +**当前问题:** 代码可能只拉取了第一页数据,未正确遍历所有页 | |
| 207 | + | |
| 208 | +**需要验证和优化:** | |
| 209 | + | |
| 210 | +```java | |
| 211 | +public void syncProducts(Long shopConfigId) { | |
| 212 | + ShopConfig shop = shopConfigMapper.selectById(shopConfigId); | |
| 213 | + | |
| 214 | + int page = 1; | |
| 215 | + int limit = 50; | |
| 216 | + boolean hasMore = true; | |
| 217 | + | |
| 218 | + while (hasMore) { // 👈 关键:循环直到没有更多数据 | |
| 219 | + // 调用店匠 API | |
| 220 | + String url = String.format( | |
| 221 | + "https://%s/openapi/2022-01/products?page=%d&limit=%d", | |
| 222 | + shop.getStoreDomain(), page, limit | |
| 223 | + ); | |
| 224 | + | |
| 225 | + ProductListResponse response = apiClient.get(url, shop.getAccessToken()); | |
| 226 | + | |
| 227 | + // 判断是否还有更多数据 | |
| 228 | + if (response.getProducts() == null || response.getProducts().isEmpty()) { | |
| 229 | + hasMore = false; // 👈 没有数据了,退出循环 | |
| 230 | + break; | |
| 231 | + } | |
| 232 | + | |
| 233 | + // 保存当前页的商品 | |
| 234 | + for (ProductDto product : response.getProducts()) { | |
| 235 | + saveProduct(shop.getTenantId(), shop.getStoreId(), product); | |
| 236 | + } | |
| 237 | + | |
| 238 | + page++; // 👈 下一页 | |
| 239 | + Thread.sleep(100); // 避免触发速率限制 | |
| 240 | + } | |
| 241 | +} | |
| 242 | +``` | |
| 243 | + | |
| 244 | +**验证要点:** | |
| 245 | +- ✅ 分页参数正确传递 | |
| 246 | +- ✅ 循环终止条件正确 | |
| 247 | +- ✅ 处理空页面情况 | |
| 248 | +- ✅ 速率限制控制 | |
| 249 | + | |
| 250 | +--- | |
| 251 | + | |
| 252 | +## 5️⃣ **批量拉取客户信息的优化 - 验证分页查询** | |
| 253 | + | |
| 254 | +**任务目标:** 与商品同步类似,完善客户数据同步 | |
| 255 | + | |
| 256 | +**实现逻辑:** | |
| 257 | +```java | |
| 258 | +public void syncCustomers(Long shopConfigId) { | |
| 259 | + // 与 syncProducts 类似,遍历所有分页 | |
| 260 | + String url = "https://{shop}/openapi/2022-01/customers?page={page}&limit=50"; | |
| 261 | + | |
| 262 | + // 循环拉取所有页 | |
| 263 | + // 保存到 shoplazza_customer 和 shoplazza_customer_address 表 | |
| 264 | +} | |
| 265 | +``` | |
| 266 | + | |
| 267 | +--- | |
| 268 | + | |
| 269 | +## 6️⃣ **批量拉取订单信息的优化 - 验证分页查询** | |
| 270 | + | |
| 271 | +**任务目标:** 完善订单数据同步 | |
| 272 | + | |
| 273 | +**实现逻辑:** | |
| 274 | +```java | |
| 275 | +public void syncOrders(Long shopConfigId) { | |
| 276 | + String url = "https://{shop}/openapi/2022-01/orders?page={page}&limit=50"; | |
| 277 | + | |
| 278 | + // 保存到 shoplazza_order 和 shoplazza_order_item 表 | |
| 279 | +} | |
| 280 | +``` | |
| 281 | + | |
| 282 | +--- | |
| 283 | + | |
| 284 | +## 7️⃣ **批量拉取店铺信息的实现 - 新增实现,需设计对应的数据库表** | |
| 285 | + | |
| 286 | +**任务目标:** 拉取店铺的详细配置信息 | |
| 287 | + | |
| 288 | +**API 调用:** | |
| 289 | +```bash | |
| 290 | +GET /openapi/2022-01/shop | |
| 291 | +``` | |
| 292 | + | |
| 293 | +**可能的响应字段:** | |
| 294 | +```json | |
| 295 | +{ | |
| 296 | + "id": "2286274", | |
| 297 | + "name": "47167113-1", | |
| 298 | + "domain": "47167113-1.myshoplaza.com", | |
| 299 | + "email": "shop@example.com", | |
| 300 | + "currency": "USD", | |
| 301 | + "timezone": "Asia/Shanghai", | |
| 302 | + "locale": "zh-CN", | |
| 303 | + "address": {...}, | |
| 304 | + "phone": "+86 123456789" | |
| 305 | +} | |
| 306 | +``` | |
| 307 | + | |
| 308 | +**需要设计的数据表:** | |
| 309 | +```sql | |
| 310 | +CREATE TABLE `shoplazza_shop_info` ( | |
| 311 | + `id` BIGINT NOT NULL AUTO_INCREMENT, | |
| 312 | + `store_id` VARCHAR(64) NOT NULL, | |
| 313 | + `shop_name` VARCHAR(255), | |
| 314 | + `domain` VARCHAR(255), | |
| 315 | + `email` VARCHAR(255), | |
| 316 | + `currency` VARCHAR(16), | |
| 317 | + `timezone` VARCHAR(64), | |
| 318 | + `locale` VARCHAR(16), | |
| 319 | + `phone` VARCHAR(64), | |
| 320 | + `address` JSON, -- 存储完整地址信息 | |
| 321 | + `plan_name` VARCHAR(64), -- 套餐名称 | |
| 322 | + `created_at` DATETIME, | |
| 323 | + `updated_at` DATETIME, | |
| 324 | + PRIMARY KEY (`id`), | |
| 325 | + UNIQUE KEY `uk_store_id` (`store_id`) | |
| 326 | +) COMMENT='店铺详细信息表'; | |
| 327 | +``` | |
| 328 | + | |
| 329 | +--- | |
| 330 | + | |
| 331 | +## 8️⃣ **注册店铺的 Webhook - 新增实现,需考虑安全验证** | |
| 332 | + | |
| 333 | +**任务目标:** 为每个店铺注册 Webhook,接收实时数据变更通知 | |
| 334 | + | |
| 335 | +**实现步骤:** | |
| 336 | + | |
| 337 | +### A. 注册 Webhook(后端主动调用) | |
| 338 | + | |
| 339 | +```java | |
| 340 | +@Service | |
| 341 | +public class WebhookService { | |
| 342 | + | |
| 343 | + private static final List<String> WEBHOOK_TOPICS = Arrays.asList( | |
| 344 | + "products/create", "products/update", "products/delete", | |
| 345 | + "orders/create", "orders/updated", "customers/create" | |
| 346 | + ); | |
| 347 | + | |
| 348 | + public void registerWebhooks(Long shopConfigId) { | |
| 349 | + ShopConfig shop = shopConfigMapper.selectById(shopConfigId); | |
| 350 | + String webhookUrl = "https://your-domain.com/webhook/shoplazza/" + shop.getStoreId(); | |
| 351 | + | |
| 352 | + for (String topic : WEBHOOK_TOPICS) { | |
| 353 | + // 调用店匠 API 注册 | |
| 354 | + apiClient.post( | |
| 355 | + "https://" + shop.getStoreDomain() + "/openapi/2022-01/webhooks", | |
| 356 | + shop.getAccessToken(), | |
| 357 | + Map.of("address", webhookUrl, "topic", topic) | |
| 358 | + ); | |
| 359 | + } | |
| 360 | + } | |
| 361 | +} | |
| 362 | +``` | |
| 363 | + | |
| 364 | +### B. 接收 Webhook(店匠主动推送) | |
| 365 | + | |
| 366 | +```java | |
| 367 | +@RestController | |
| 368 | +@RequestMapping("/webhook/shoplazza") | |
| 369 | +public class WebhookController { | |
| 370 | + | |
| 371 | + @PostMapping("/{storeId}") | |
| 372 | + public ResponseEntity<String> handleWebhook( | |
| 373 | + @PathVariable String storeId, | |
| 374 | + @RequestHeader("X-Shoplazza-Hmac-Sha256") String signature, // 👈 安全验证 | |
| 375 | + @RequestHeader("X-Shoplazza-Topic") String topic, | |
| 376 | + @RequestBody String payload) { | |
| 377 | + | |
| 378 | + // 1. 验证签名(安全验证) | |
| 379 | + if (!verifySignature(payload, signature, clientSecret)) { | |
| 380 | + return ResponseEntity.status(401).body("Invalid signature"); | |
| 381 | + } | |
| 382 | + | |
| 383 | + // 2. 异步处理事件 | |
| 384 | + webhookService.processAsync(storeId, topic, payload); | |
| 385 | + | |
| 386 | + // 3. 立即返回 200(店匠要求3秒内响应) | |
| 387 | + return ResponseEntity.ok("OK"); | |
| 388 | + } | |
| 389 | + | |
| 390 | + // HMAC-SHA256 签名验证 | |
| 391 | + private boolean verifySignature(String payload, String signature, String secret) { | |
| 392 | + Mac mac = Mac.getInstance("HmacSHA256"); | |
| 393 | + mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); | |
| 394 | + byte[] hash = mac.doFinal(payload.getBytes()); | |
| 395 | + String computed = Base64.getEncoder().encodeToString(hash); | |
| 396 | + return computed.equals(signature); | |
| 397 | + } | |
| 398 | +} | |
| 399 | +``` | |
| 400 | + | |
| 401 | +**安全验证关键点:** | |
| 402 | +- ✅ 使用 HMAC-SHA256 验证签名 | |
| 403 | +- ✅ 签名密钥使用 APP 的 Client Secret | |
| 404 | +- ✅ 3秒内返回响应 | |
| 405 | +- ✅ 异步处理事件,避免超时 | |
| 406 | + | |
| 407 | +--- | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | ... | ... |