# 店匠平台技术对接指南
## 1. 概述
### 1.1 店匠平台介绍
[店匠(Shoplazza)](https://www.shoplazza.com) 是一个专为跨境电商设计的独立站建站平台,类似于 Shopify。商家可以快速搭建自己的品牌独立站,进行商品销售、订单管理、客户管理等运营。
店匠提供了开放的应用生态系统,第三方开发者可以开发应用插件(APP)并发布到店匠应用市场,为商家提供增值服务。
**核心特性:**
- 独立站建站和主题装修
- 商品、订单、客户管理
- 多语言和多货币支持
- 开放的 Admin API
- Webhook 事件通知
- OAuth 2.0 授权机制
### 1.2 对接目标
本文档旨在帮助开发团队将**搜索 SaaS** 接入店匠生态,作为应用市场的搜索插件上线。
**对接目标:**
1. 在店匠应用市场发布搜索 APP
2. 商家可以安装 APP 并授权访问店铺数据
3. 自动同步商家的商品、订单、客户数据
4. 提供前端搜索扩展,嵌入商家的店铺主题
5. 为商家提供智能搜索服务(多语言、语义搜索、AI 搜索)
6. 统计分析商家的搜索行为数据
### 1.3 系统架构
```mermaid
graph TB
subgraph "店匠平台"
A[店匠应用市场]
B[商家店铺]
C[店匠 Admin API]
D[店匠 Webhook]
end
subgraph "搜索 SaaS 平台"
E[OAuth 服务]
F[数据同步服务]
G[Webhook 接收服务]
H[搜索 API 服务]
I[管理后台]
J[数据库
MySQL]
K[搜索引擎
Elasticsearch]
end
subgraph "前端扩展"
L[搜索入口组件]
M[搜索结果页]
end
A -->|商家安装| E
B -->|OAuth授权| E
E -->|获取Token| F
F -->|调用API| C
C -->|返回数据| F
F -->|存储| J
F -->|索引| K
D -->|推送事件| G
G -->|增量更新| J
G -->|增量索引| K
B -->|装修主题| L
L -->|搜索请求| H
M -->|搜索请求| H
H -->|查询| K
I -->|管理| J
```
### 1.4 技术栈要求
**后端服务:**
- Java(Spring Boot):OAuth、数据同步、API 网关
- Python(FastAPI):搜索服务、向量检索
- MySQL:存储店铺、商品、订单等数据
- Elasticsearch:商品索引和搜索
**前端扩展:**
- Liquid 模板语言(店匠主题)
- JavaScript/TypeScript
- HTML/CSS
**基础设施:**
- 公网域名(支持 HTTPS)
- SSL 证书
- 服务器(支持 Docker 部署)
### 1.5 前置条件
在开始对接之前,请确保:
1. ✅ 已注册店匠 Partner 账号
2. ✅ 拥有公网域名和 HTTPS 证书
3. ✅ 已部署搜索 SaaS 后端服务
4. ✅ 拥有测试店铺(用于开发和调试)
5. ✅ 熟悉 OAuth 2.0 授权流程
6. ✅ 熟悉 RESTful API 开发
---
## 2. 开发者准备
### 2.1 注册店匠 Partner 账号
1. 访问 [店匠合作伙伴中心](https://partners.shoplazza.com)
2. 点击"注册"按钮,填写公司信息
3. 完成邮箱验证和资质审核
4. 登录 Partner 后台
### 2.2 创建 APP 应用
1. 登录 [店匠 Partner 后台](https://partners.shoplazza.com)
2. 在左侧导航栏选择"Apps"
3. 点击"Create App"按钮
4. 填写 APP 基本信息:
- **App Name**:搜索 SaaS(或自定义名称)
- **App Type**:Public App(公开应用)
- **Category**:Search & Discovery(搜索与发现)
5. 系统自动生成:
- **Client ID**:应用的唯一标识
- **Client Secret**:应用密钥(请妥善保管)
### 2.3 配置 APP 信息
在 APP 设置页面,配置以下关键信息:
#### 2.3.1 OAuth 配置
```yaml
Client ID: m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es
Client Secret: m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo
```
**重定向 URI(Redirect URI):**
```
https://your-domain.com/oauth/callback
```
**注意事项:**
- Redirect URI 必须使用 HTTPS 协议
- 必须是公网可访问的地址
- 开发环境可以使用 ngrok 等工具暴露本地服务
#### 2.3.2 应用权限(Scopes)
根据业务需求,申请以下权限:
| 权限 Scope | 说明 | 是否必需 |
|------------|------|----------|
| `read_shop` | 读取店铺信息 | ✅ 必需 |
| `write_shop` | 修改店铺信息 | ❌ 可选 |
| `read_product` | 读取商品信息 | ✅ 必需 |
| `write_product` | 修改商品信息 | ❌ 可选 |
| `read_order` | 读取订单信息 | ✅ 必需 |
| `read_customer` | 读取客户信息 | ✅ 必需 |
| `read_app_proxy` | APP 代理访问 | ✅ 必需 |
| `write_cart_transform` | 购物车转换(如需价格调整) | ❌ 可选 |
**配置示例:**
```go
Scopes: []string{
"read_shop",
"read_product",
"read_order",
"read_customer",
"read_app_proxy",
}
```
#### 2.3.3 Webhook 配置(后续注册)
Webhook 地址(后续在代码中动态注册):
```
https://your-domain.com/webhook/shoplazza
```
### 2.4 准备测试店铺
1. 在店匠平台注册一个测试店铺
2. 在店铺中添加测试商品、客户、订单数据
3. 记录店铺域名:`{shop-name}.myshoplaza.com`
**注意:** 部分功能(如 Webhook 注册)需要店铺激活后才能使用。
---
## 3. OAuth 2.0 认证实现
### 3.1 OAuth 授权流程
店匠使用标准的 OAuth 2.0 授权码(Authorization Code)流程:
```mermaid
sequenceDiagram
participant 商家
participant 店匠平台
participant 搜索SaaS
商家->>店匠平台: 1. 在应用市场点击"安装"
店匠平台->>搜索SaaS: 2. 跳转到 APP URI
搜索SaaS->>店匠平台: 3. 重定向到授权页面
店匠平台->>商家: 4. 显示授权确认页
商家->>店匠平台: 5. 点击"授权"
店匠平台->>搜索SaaS: 6. 回调 Redirect URI(带 code)
搜索SaaS->>店匠平台: 7. 用 code 换取 Access Token
店匠平台->>搜索SaaS: 8. 返回 Access Token
搜索SaaS->>搜索SaaS: 9. 保存 Token 到数据库
搜索SaaS->>商家: 10. 显示安装成功页面
```
### 3.2 实现步骤
#### 3.2.1 配置 OAuth 客户端
在应用启动时,初始化 OAuth 配置:
```go
// OAuth 配置
type OAuthConfig struct {
ClientID string
ClientSecret string
RedirectURI string
Scopes []string
AuthURL string
TokenURL string
}
// 初始化配置
config := &OAuthConfig{
ClientID: "m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es",
ClientSecret: "m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo",
RedirectURI: "https://your-domain.com/oauth/callback",
Scopes: []string{
"read_shop",
"read_product",
"read_order",
"read_customer",
"read_app_proxy",
},
AuthURL: "https://partners.shoplazza.com/partner/oauth/authorize",
TokenURL: "https://partners.shoplazza.com/partner/oauth/token",
}
```
#### 3.2.2 处理 APP URI 请求
当商家在应用市场点击"安装"时,店匠会跳转到你配置的 APP URI:
```
GET https://your-domain.com/oauth/install?shop={shop_domain}
```
**处理逻辑:**
```http
GET /oauth/install
Query Parameters:
- shop: 店铺域名,例如 47167113-1.myshoplaza.com
Response:
302 Redirect to Authorization URL
```
**生成授权 URL:**
```go
// 构建授权 URL
func buildAuthURL(config *OAuthConfig, shop string) string {
params := url.Values{}
params.Add("client_id", config.ClientID)
params.Add("redirect_uri", config.RedirectURI)
params.Add("scope", strings.Join(config.Scopes, " "))
params.Add("state", shop) // 使用 shop 作为 state
return config.AuthURL + "?" + params.Encode()
}
```
**授权 URL 示例:**
```
https://partners.shoplazza.com/partner/oauth/authorize?
client_id=m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es
&redirect_uri=https://your-domain.com/oauth/callback
&scope=read_shop read_product read_order read_customer read_app_proxy
&state=47167113-1.myshoplaza.com
```
#### 3.2.3 处理授权回调
商家授权后,店匠会回调你的 Redirect URI:
```
GET https://your-domain.com/oauth/callback?code={auth_code}&shop={shop_domain}&state={state}
```
**回调参数:**
- `code`:授权码(用于换取 Access Token)
- `shop`:店铺域名
- `state`:之前传递的 state 参数
**处理逻辑:**
```http
GET /oauth/callback
Query Parameters:
- code: 授权码
- shop: 店铺域名
- state: state 参数
Response:
200 OK (HTML 页面显示安装成功)
```
#### 3.2.4 换取 Access Token
使用授权码换取 Access Token:
**请求示例(curl):**
```bash
curl --request POST \
--url https://partners.shoplazza.com/partner/oauth/token \
--header 'Content-Type: application/json' \
--data '{
"client_id": "m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es",
"client_secret": "m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo",
"code": "{authorization_code}",
"grant_type": "authorization_code",
"redirect_uri": "https://your-domain.com/oauth/callback"
}'
```
**响应示例:**
```json
{
"access_token": "V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY",
"token_type": "Bearer",
"refresh_token": "-QP6o5YpsqC47q5D2M3xHJ0YP4SPcybhm5oYlPaMUOo",
"expires_in": 31556951,
"created_at": 1740793402,
"store_id": "2286274",
"store_name": "47167113-1",
"expires_at": 1772350354,
"locale": "zh-CN"
}
```
**响应字段说明:**
- `access_token`:访问令牌(调用 Admin API 时使用)
- `refresh_token`:刷新令牌(用于刷新 Access Token)
- `expires_in`:过期时间(秒)
- `expires_at`:过期时间戳
- `store_id`:店铺 ID
- `store_name`:店铺名称
- `locale`:店铺语言
#### 3.2.5 保存 Token 到数据库
将 Token 信息保存到数据库(详见第 4 章数据模型):
```sql
INSERT INTO shoplazza_shop_config (
store_id,
store_name,
store_domain,
access_token,
refresh_token,
token_expires_at,
locale,
status,
created_at,
updated_at
) VALUES (
'2286274',
'47167113-1',
'47167113-1.myshoplaza.com',
'V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY',
'-QP6o5YpsqC47q5D2M3xHJ0YP4SPcybhm5oYlPaMUOo',
'2026-11-02 23:21:14',
'zh-CN',
'active',
NOW(),
NOW()
);
```
### 3.3 Token 刷新机制
Access Token 会过期(通常为 1 年),过期后需要使用 Refresh Token 刷新。
**刷新 Token 请求:**
```bash
curl --request POST \
--url https://partners.shoplazza.com/partner/oauth/token \
--header 'Content-Type: application/json' \
--data '{
"client_id": "m8F9PrPnxpyrlz4ONBWRoINsa5xyNT4Qd-Fh_h7o1es",
"client_secret": "m2cDNrBqAa8TKeridXd4eXnhi9E7pda2gKXet_72rjo",
"refresh_token": "-QP6o5YpsqC47q5D2M3xHJ0YP4SPcybhm5oYlPaMUOo",
"grant_type": "refresh_token"
}'
```
**响应格式与获取 Token 时相同。**
**刷新策略:**
1. 在 Token 过期前 7 天开始尝试刷新
2. API 调用返回 401 Unauthorized 时立即刷新
3. 刷新成功后更新数据库中的 Token 信息
### 3.4 安装成功页面
OAuth 回调处理完成后,返回一个 HTML 页面告知商家安装成功:
```html
安装成功 - 搜索 SaaS
✓
安装成功!
搜索 SaaS 已成功安装到您的店铺
店铺名称:{{store_name}}
下一步操作:
- 进入店铺后台 → 主题装修
- 点击"添加卡片" → 选择"APPS" → 找到"搜索 SaaS"
- 拖拽搜索组件到页面中
- 保存并发布主题
前往店铺后台
```
---
## 4. 租户和店铺管理
### 4.1 数据模型设计
#### 4.1.1 租户表(system_tenant)
每个店铺在 SaaS 平台都是一个独立的租户。
```sql
CREATE TABLE `system_tenant` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '租户ID',
`name` VARCHAR(255) NOT NULL COMMENT '租户名称',
`package_id` BIGINT DEFAULT NULL COMMENT '套餐ID',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`expire_time` DATETIME DEFAULT NULL COMMENT '过期时间',
`account_count` INT DEFAULT 0 COMMENT '账号数量',
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
KEY `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户表';
```
#### 4.1.2 店铺配置表(shoplazza_shop_config)
存储店铺的基本信息和 OAuth Token。
```sql
CREATE TABLE `shoplazza_shop_config` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`store_id` VARCHAR(64) NOT NULL COMMENT '店匠店铺ID',
`store_name` VARCHAR(255) NOT NULL COMMENT '店铺名称',
`store_domain` VARCHAR(255) NOT NULL COMMENT '店铺域名',
`access_token` VARCHAR(512) NOT NULL COMMENT 'Access Token',
`refresh_token` VARCHAR(512) DEFAULT NULL COMMENT 'Refresh Token',
`token_expires_at` DATETIME NOT NULL COMMENT 'Token过期时间',
`locale` VARCHAR(16) DEFAULT 'zh-CN' COMMENT '店铺语言',
`currency` VARCHAR(16) DEFAULT 'USD' COMMENT '店铺货币',
`timezone` VARCHAR(64) DEFAULT 'Asia/Shanghai' COMMENT '店铺时区',
`status` VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '状态:active-激活,inactive-未激活,suspended-暂停',
`install_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '安装时间',
`last_sync_time` DATETIME DEFAULT NULL COMMENT '最后同步时间',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_store_id` (`store_id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_store_domain` (`store_domain`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠店铺配置表';
```
### 4.2 Token 管理策略
#### 4.2.1 Token 存储
- ✅ 加密存储 Access Token 和 Refresh Token
- ✅ 记录 Token 过期时间
- ✅ 记录最后刷新时间
#### 4.2.2 Token 自动刷新
```java
public class TokenRefreshService {
/**
* 检查并刷新即将过期的 Token
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void refreshExpiringTokens() {
// 查询7天内过期的 Token
DateTime sevenDaysLater = DateTime.now().plusDays(7);
List shops = shopConfigMapper.selectExpiringTokens(sevenDaysLater);
for (ShopConfig shop : shops) {
try {
// 刷新 Token
TokenResponse newToken = oauthClient.refreshToken(shop.getRefreshToken());
// 更新数据库
shop.setAccessToken(newToken.getAccessToken());
shop.setRefreshToken(newToken.getRefreshToken());
shop.setTokenExpiresAt(newToken.getExpiresAt());
shopConfigMapper.updateById(shop);
log.info("Token refreshed for shop: {}", shop.getStoreName());
} catch (Exception e) {
log.error("Failed to refresh token for shop: {}", shop.getStoreName(), e);
// 可选:发送告警通知
}
}
}
/**
* API 调用时检查 Token 是否过期
*/
public String getValidAccessToken(String storeId) {
ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
if (shop == null) {
throw new BusinessException("Shop not found: " + storeId);
}
// 检查是否即将过期(提前1小时)
if (shop.getTokenExpiresAt().isBefore(DateTime.now().plusHours(1))) {
// 刷新 Token
TokenResponse newToken = oauthClient.refreshToken(shop.getRefreshToken());
// 更新数据库
shop.setAccessToken(newToken.getAccessToken());
shop.setRefreshToken(newToken.getRefreshToken());
shop.setTokenExpiresAt(newToken.getExpiresAt());
shopConfigMapper.updateById(shop);
}
return shop.getAccessToken();
}
}
```
### 4.3 租户创建流程
当商家完成 OAuth 授权后,自动创建租户和店铺配置:
```java
@Transactional
public void handleOAuthCallback(TokenResponse tokenResponse) {
String storeId = tokenResponse.getStoreId();
String storeName = tokenResponse.getStoreName();
// 1. 检查租户是否已存在
Tenant tenant = tenantMapper.selectByStoreId(storeId);
if (tenant == null) {
// 创建新租户
tenant = new Tenant();
tenant.setName(storeName);
tenant.setStatus(1); // 启用
tenant.setPackageId(1L); // 默认套餐
tenantMapper.insert(tenant);
}
// 2. 创建或更新店铺配置
ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
if (shop == null) {
shop = new ShopConfig();
shop.setTenantId(tenant.getId());
shop.setStoreId(storeId);
shop.setStoreName(storeName);
}
// 更新 Token 信息
shop.setAccessToken(tokenResponse.getAccessToken());
shop.setRefreshToken(tokenResponse.getRefreshToken());
shop.setTokenExpiresAt(tokenResponse.getExpiresAt());
shop.setLocale(tokenResponse.getLocale());
shop.setStatus("active");
shop.setInstallTime(new Date());
if (shop.getId() == null) {
shopConfigMapper.insert(shop);
} else {
shopConfigMapper.updateById(shop);
}
// 3. 触发首次数据同步
dataSyncService.syncAllData(shop.getId());
}
```
---
## 5. 店匠 Admin API 调用
### 5.1 API 认证方式
调用店匠 Admin API 时,需要在请求头中携带 Access Token:
```http
GET /openapi/2022-01/products
Host: {shop-domain}.myshoplaza.com
access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY
Content-Type: application/json
```
**注意:** 请求头字段名是 `access-token`,不是 `Authorization`。
### 5.2 API 端点基础 URL
店匠 API 的基础 URL 格式:
```
https://{shop-domain}.myshoplaza.com/openapi/{version}/{resource}
```
**参数说明:**
- `{shop-domain}`:店铺域名,例如 `47167113-1`
- `{version}`:API 版本,目前为 `2022-01`
- `{resource}`:资源路径,例如 `products`、`orders`
**示例:**
```
https://47167113-1.myshoplaza.com/openapi/2022-01/products
```
### 5.3 常用 API 端点
#### 5.3.1 店铺信息
```bash
# 获取店铺详情
GET /openapi/2022-01/shop
```
#### 5.3.2 商品管理
```bash
# 获取商品列表
GET /openapi/2022-01/products?page=1&limit=50
# 获取商品详情
GET /openapi/2022-01/products/{product_id}
# 获取商品总数
GET /openapi/2022-01/products/count
```
#### 5.3.3 订单管理
```bash
# 获取订单列表
GET /openapi/2022-01/orders?page=1&limit=50
# 获取订单详情
GET /openapi/2022-01/orders/{order_id}
# 获取订单总数
GET /openapi/2022-01/orders/count
```
#### 5.3.4 客户管理
```bash
# 获取客户列表
GET /openapi/2022-01/customers?page=1&limit=50
# 获取客户详情
GET /openapi/2022-01/customers/{customer_id}
# 获取客户总数
GET /openapi/2022-01/customers/count
```
### 5.4 请求和响应格式
#### 5.4.1 分页查询
店匠 API 使用基于页码的分页:
```http
GET /openapi/2022-01/products?page=1&limit=50&status=active
```
**分页参数:**
- `page`:页码,从 1 开始
- `limit`:每页数量,最大 250
**响应格式:**
```json
{
"products": [
{
"id": "123456",
"title": "Product Name",
"variants": [...],
...
}
]
}
```
#### 5.4.2 错误响应
API 调用失败时返回错误信息:
```json
{
"error": "Unauthorized",
"error_description": "Invalid access token"
}
```
**常见错误码:**
- `400 Bad Request`:请求参数错误
- `401 Unauthorized`:Token 无效或过期
- `403 Forbidden`:权限不足
- `404 Not Found`:资源不存在
- `429 Too Many Requests`:触发速率限制
- `500 Internal Server Error`:服务器错误
### 5.5 错误处理和重试策略
```java
public class ShoplazzaApiClient {
private static final int MAX_RETRIES = 3;
private static final int RETRY_DELAY_MS = 1000;
/**
* 调用 API 并处理错误
*/
public T callApi(String storeId, String endpoint, Class responseType) {
int retries = 0;
Exception lastException = null;
while (retries < MAX_RETRIES) {
try {
// 获取有效的 Access Token
String accessToken = tokenManager.getValidAccessToken(storeId);
// 构建请求
HttpHeaders headers = new HttpHeaders();
headers.set("access-token", accessToken);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity> entity = new HttpEntity<>(headers);
// 发送请求
ResponseEntity response = restTemplate.exchange(
endpoint,
HttpMethod.GET,
entity,
responseType
);
return response.getBody();
} catch (HttpClientErrorException e) {
if (e.getStatusCode() == HttpStatus.UNAUTHORIZED) {
// Token 过期,刷新后重试
tokenManager.forceRefreshToken(storeId);
retries++;
continue;
} else if (e.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) {
// 触发速率限制,等待后重试
sleep(RETRY_DELAY_MS * (retries + 1));
retries++;
continue;
} else {
throw new BusinessException("API call failed: " + e.getMessage());
}
} catch (Exception e) {
lastException = e;
retries++;
sleep(RETRY_DELAY_MS);
}
}
throw new BusinessException("API call failed after retries", lastException);
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
```
### 5.6 速率限制处理
店匠 API 有速率限制(Rate Limit),需要遵守以下规则:
**限制说明:**
- 每个店铺每秒最多 10 个请求
- 响应头中包含速率限制信息
**响应头示例:**
```
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 8
X-RateLimit-Reset: 1699800060
```
**处理策略:**
1. 解析响应头中的速率限制信息
2. 如果 `X-RateLimit-Remaining` 为 0,等待到 `X-RateLimit-Reset` 时间
3. 收到 429 错误时,使用指数退避重试
---
## 6. 数据同步实现
### 6.1 商品数据同步
#### 6.1.1 API 调用
**获取商品列表:**
```bash
curl --request GET \
--url 'https://47167113-1.myshoplaza.com/openapi/2022-01/products?page=1&limit=50' \
--header 'access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY' \
--header 'accept: application/json'
```
**响应示例:**
```json
{
"products": [
{
"id": "193817395",
"title": "蓝牙耳机",
"body_html": "高品质蓝牙耳机
",
"vendor": "Sony",
"product_type": "Electronics",
"handle": "bluetooth-headphone",
"published_at": "2024-01-15T10:00:00Z",
"created_at": "2024-01-15T09:00:00Z",
"updated_at": "2024-01-20T14:30:00Z",
"status": "active",
"tags": "electronics, audio, bluetooth",
"variants": [
{
"id": "819403847",
"product_id": "193817395",
"title": "Black / Standard",
"price": "99.99",
"compare_at_price": "129.99",
"sku": "BT-HP-001",
"inventory_quantity": 100,
"weight": "0.25",
"weight_unit": "kg",
"requires_shipping": true,
"option1": "Black",
"option2": "Standard",
"option3": null
}
],
"images": [
{
"id": "638746512",
"product_id": "193817395",
"src": "https://cdn.shoplazza.com/image1.jpg",
"position": 1,
"width": 800,
"height": 800
}
],
"options": [
{
"id": "123456",
"name": "Color",
"values": ["Black", "White", "Blue"]
},
{
"id": "123457",
"name": "Size",
"values": ["Standard"]
}
]
}
]
}
```
#### 6.1.2 数据表设计
**SPU 表(shoplazza_product_spu):**
```sql
CREATE TABLE `shoplazza_product_spu` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
`product_id` VARCHAR(64) NOT NULL COMMENT '店匠商品ID',
`title` VARCHAR(512) NOT NULL COMMENT '商品标题',
`body_html` TEXT COMMENT '商品描述HTML',
`vendor` VARCHAR(255) DEFAULT NULL COMMENT '供应商/品牌',
`product_type` VARCHAR(255) DEFAULT NULL COMMENT '商品类型',
`handle` VARCHAR(255) DEFAULT NULL COMMENT '商品URL handle',
`tags` VARCHAR(1024) DEFAULT NULL COMMENT '标签(逗号分隔)',
`status` VARCHAR(32) DEFAULT 'active' COMMENT '状态:active, draft, archived',
`published_at` DATETIME DEFAULT NULL COMMENT '发布时间',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_store_product` (`store_id`, `product_id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_product_type` (`product_type`),
KEY `idx_vendor` (`vendor`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠商品SPU表';
```
**SKU 表(shoplazza_product_sku):**
```sql
CREATE TABLE `shoplazza_product_sku` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
`product_id` VARCHAR(64) NOT NULL COMMENT '店匠商品ID',
`variant_id` VARCHAR(64) NOT NULL COMMENT '店匠变体ID',
`sku` VARCHAR(255) DEFAULT NULL COMMENT 'SKU编码',
`title` VARCHAR(512) NOT NULL COMMENT '变体标题',
`price` DECIMAL(12,2) NOT NULL COMMENT '价格',
`compare_at_price` DECIMAL(12,2) DEFAULT NULL COMMENT '对比价格',
`inventory_quantity` INT DEFAULT 0 COMMENT '库存数量',
`weight` DECIMAL(10,3) DEFAULT NULL COMMENT '重量',
`weight_unit` VARCHAR(16) DEFAULT NULL COMMENT '重量单位',
`option1` VARCHAR(255) DEFAULT NULL COMMENT '选项1值',
`option2` VARCHAR(255) DEFAULT NULL COMMENT '选项2值',
`option3` VARCHAR(255) DEFAULT NULL COMMENT '选项3值',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_store_variant` (`store_id`, `variant_id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_product_id` (`product_id`),
KEY `idx_sku` (`sku`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠商品SKU表';
```
**图片表(shoplazza_product_image):**
```sql
CREATE TABLE `shoplazza_product_image` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
`product_id` VARCHAR(64) NOT NULL COMMENT '店匠商品ID',
`image_id` VARCHAR(64) NOT NULL COMMENT '店匠图片ID',
`src` VARCHAR(1024) NOT NULL COMMENT '图片URL',
`position` INT DEFAULT 1 COMMENT '排序位置',
`width` INT DEFAULT NULL COMMENT '图片宽度',
`height` INT DEFAULT NULL COMMENT '图片高度',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_store_image` (`store_id`, `image_id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠商品图片表';
```
#### 6.1.3 同步逻辑实现
```java
@Service
public class ProductSyncService {
@Autowired
private ShoplazzaApiClient apiClient;
@Autowired
private ProductSpuMapper spuMapper;
@Autowired
private ProductSkuMapper skuMapper;
@Autowired
private ProductImageMapper imageMapper;
/**
* 同步单个店铺的所有商品
*/
public void syncProducts(Long shopConfigId) {
ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
if (shop == null) {
throw new BusinessException("Shop not found");
}
int page = 1;
int limit = 50;
boolean hasMore = true;
int totalSynced = 0;
while (hasMore) {
try {
// 调用 API 获取商品列表
String endpoint = String.format(
"https://%s.myshoplaza.com/openapi/2022-01/products?page=%d&limit=%d",
shop.getStoreDomain().split("\\.")[0],
page,
limit
);
ProductListResponse response = apiClient.callApi(
shop.getStoreId(),
endpoint,
ProductListResponse.class
);
if (response.getProducts() == null || response.getProducts().isEmpty()) {
hasMore = false;
break;
}
// 保存商品数据
for (ProductDto product : response.getProducts()) {
saveProduct(shop.getTenantId(), shop.getStoreId(), product);
totalSynced++;
}
log.info("Synced page {} for shop {}, total: {}", page, shop.getStoreName(), totalSynced);
// 下一页
page++;
// 避免触发速率限制
Thread.sleep(100);
} catch (Exception e) {
log.error("Failed to sync products for shop: {}", shop.getStoreName(), e);
throw new BusinessException("Product sync failed", e);
}
}
// 更新最后同步时间
shop.setLastSyncTime(new Date());
shopConfigMapper.updateById(shop);
log.info("Product sync completed for shop: {}, total synced: {}", shop.getStoreName(), totalSynced);
}
/**
* 保存单个商品及其SKU和图片
*/
@Transactional
private void saveProduct(Long tenantId, String storeId, ProductDto product) {
// 1. 保存 SPU
ProductSpu spu = spuMapper.selectByStoreAndProductId(storeId, product.getId());
if (spu == null) {
spu = new ProductSpu();
spu.setTenantId(tenantId);
spu.setStoreId(storeId);
spu.setProductId(product.getId());
}
spu.setTitle(product.getTitle());
spu.setBodyHtml(product.getBodyHtml());
spu.setVendor(product.getVendor());
spu.setProductType(product.getProductType());
spu.setHandle(product.getHandle());
spu.setTags(product.getTags());
spu.setStatus(product.getStatus());
spu.setPublishedAt(product.getPublishedAt());
if (spu.getId() == null) {
spuMapper.insert(spu);
} else {
spuMapper.updateById(spu);
}
// 2. 保存 SKU
if (product.getVariants() != null) {
for (VariantDto variant : product.getVariants()) {
ProductSku sku = skuMapper.selectByStoreAndVariantId(storeId, variant.getId());
if (sku == null) {
sku = new ProductSku();
sku.setTenantId(tenantId);
sku.setStoreId(storeId);
sku.setProductId(product.getId());
sku.setVariantId(variant.getId());
}
sku.setSku(variant.getSku());
sku.setTitle(variant.getTitle());
sku.setPrice(new BigDecimal(variant.getPrice()));
sku.setCompareAtPrice(variant.getCompareAtPrice() != null ?
new BigDecimal(variant.getCompareAtPrice()) : null);
sku.setInventoryQuantity(variant.getInventoryQuantity());
sku.setWeight(variant.getWeight());
sku.setWeightUnit(variant.getWeightUnit());
sku.setOption1(variant.getOption1());
sku.setOption2(variant.getOption2());
sku.setOption3(variant.getOption3());
if (sku.getId() == null) {
skuMapper.insert(sku);
} else {
skuMapper.updateById(sku);
}
}
}
// 3. 保存图片
if (product.getImages() != null) {
for (ImageDto image : product.getImages()) {
ProductImage img = imageMapper.selectByStoreAndImageId(storeId, image.getId());
if (img == null) {
img = new ProductImage();
img.setTenantId(tenantId);
img.setStoreId(storeId);
img.setProductId(product.getId());
img.setImageId(image.getId());
}
img.setSrc(image.getSrc());
img.setPosition(image.getPosition());
img.setWidth(image.getWidth());
img.setHeight(image.getHeight());
if (img.getId() == null) {
imageMapper.insert(img);
} else {
imageMapper.updateById(img);
}
}
}
}
}
```
### 6.2 客户数据同步
#### 6.2.1 数据表设计
**客户表(shoplazza_customer):**
```sql
CREATE TABLE `shoplazza_customer` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
`customer_id` VARCHAR(64) NOT NULL COMMENT '店匠客户ID',
`email` VARCHAR(255) DEFAULT NULL COMMENT '邮箱',
`phone` VARCHAR(64) DEFAULT NULL COMMENT '电话',
`first_name` VARCHAR(128) DEFAULT NULL COMMENT '名',
`last_name` VARCHAR(128) DEFAULT NULL COMMENT '姓',
`orders_count` INT DEFAULT 0 COMMENT '订单数量',
`total_spent` DECIMAL(12,2) DEFAULT 0.00 COMMENT '累计消费',
`state` VARCHAR(32) DEFAULT NULL COMMENT '状态',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_store_customer` (`store_id`, `customer_id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠客户表';
```
**客户地址表(shoplazza_customer_address):**
```sql
CREATE TABLE `shoplazza_customer_address` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
`customer_id` VARCHAR(64) NOT NULL COMMENT '店匠客户ID',
`address_id` VARCHAR(64) NOT NULL COMMENT '店匠地址ID',
`first_name` VARCHAR(128) DEFAULT NULL COMMENT '名',
`last_name` VARCHAR(128) DEFAULT NULL COMMENT '姓',
`address1` VARCHAR(512) DEFAULT NULL COMMENT '地址行1',
`address2` VARCHAR(512) DEFAULT NULL COMMENT '地址行2',
`city` VARCHAR(128) DEFAULT NULL COMMENT '城市',
`province` VARCHAR(128) DEFAULT NULL COMMENT '省份',
`country` VARCHAR(128) DEFAULT NULL COMMENT '国家',
`zip` VARCHAR(32) DEFAULT NULL COMMENT '邮编',
`phone` VARCHAR(64) DEFAULT NULL COMMENT '电话',
`is_default` BIT(1) DEFAULT b'0' COMMENT '是否默认地址',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_store_address` (`store_id`, `address_id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_customer_id` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠客户地址表';
```
#### 6.2.2 API 调用示例
```bash
curl --request GET \
--url 'https://47167113-1.myshoplaza.com/openapi/2022-01/customers?page=1&limit=50' \
--header 'access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY' \
--header 'accept: application/json'
```
### 6.3 订单数据同步
#### 6.3.1 数据表设计
**订单表(shoplazza_order):**
```sql
CREATE TABLE `shoplazza_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
`order_id` VARCHAR(64) NOT NULL COMMENT '店匠订单ID',
`order_number` VARCHAR(128) NOT NULL COMMENT '订单号',
`customer_id` VARCHAR(64) DEFAULT NULL COMMENT '客户ID',
`email` VARCHAR(255) DEFAULT NULL COMMENT '客户邮箱',
`total_price` DECIMAL(12,2) NOT NULL COMMENT '订单总价',
`subtotal_price` DECIMAL(12,2) DEFAULT NULL COMMENT '小计',
`total_tax` DECIMAL(12,2) DEFAULT NULL COMMENT '税费',
`total_shipping` DECIMAL(12,2) DEFAULT NULL COMMENT '运费',
`currency` VARCHAR(16) DEFAULT 'USD' COMMENT '货币',
`financial_status` VARCHAR(32) DEFAULT NULL COMMENT '支付状态',
`fulfillment_status` VARCHAR(32) DEFAULT NULL COMMENT '配送状态',
`order_status` VARCHAR(32) DEFAULT NULL COMMENT '订单状态',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_store_order` (`store_id`, `order_id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_customer_id` (`customer_id`),
KEY `idx_order_number` (`order_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠订单表';
```
**订单明细表(shoplazza_order_item):**
```sql
CREATE TABLE `shoplazza_order_item` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
`order_id` VARCHAR(64) NOT NULL COMMENT '店匠订单ID',
`line_item_id` VARCHAR(64) NOT NULL COMMENT '店匠明细ID',
`product_id` VARCHAR(64) DEFAULT NULL COMMENT '商品ID',
`variant_id` VARCHAR(64) DEFAULT NULL COMMENT '变体ID',
`sku` VARCHAR(255) DEFAULT NULL COMMENT 'SKU',
`title` VARCHAR(512) DEFAULT NULL COMMENT '商品标题',
`quantity` INT NOT NULL COMMENT '数量',
`price` DECIMAL(12,2) NOT NULL COMMENT '单价',
`total_price` DECIMAL(12,2) NOT NULL COMMENT '总价',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_store_line_item` (`store_id`, `line_item_id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠订单明细表';
```
### 6.4 同步调度策略
#### 6.4.1 首次全量同步
商家安装 APP 后,触发首次全量同步:
```java
@Service
public class DataSyncService {
@Async
public void syncAllData(Long shopConfigId) {
log.info("Starting full data sync for shop: {}", shopConfigId);
try {
// 1. 同步商品(优先级最高)
productSyncService.syncProducts(shopConfigId);
// 2. 同步客户
customerSyncService.syncCustomers(shopConfigId);
// 3. 同步订单
orderSyncService.syncOrders(shopConfigId);
// 4. 注册 Webhook
webhookService.registerWebhooks(shopConfigId);
// 5. 索引商品到 ES
esIndexService.indexProducts(shopConfigId);
log.info("Full data sync completed for shop: {}", shopConfigId);
} catch (Exception e) {
log.error("Full data sync failed for shop: {}", shopConfigId, e);
// 可选:发送告警通知
}
}
}
```
#### 6.4.2 定时增量同步
配置定时任务,定期同步数据:
```java
@Component
public class ScheduledSyncTask {
@Autowired
private DataSyncService dataSyncService;
@Autowired
private ShopConfigMapper shopConfigMapper;
/**
* 每小时同步一次商品数据
*/
@Scheduled(cron = "0 0 * * * ?")
public void syncProductsHourly() {
List activeShops = shopConfigMapper.selectActiveShops();
for (ShopConfig shop : activeShops) {
try {
productSyncService.syncProducts(shop.getId());
} catch (Exception e) {
log.error("Scheduled product sync failed for shop: {}", shop.getStoreName(), e);
}
}
}
/**
* 每天同步一次客户和订单数据
*/
@Scheduled(cron = "0 0 3 * * ?")
public void syncCustomersAndOrdersDaily() {
List activeShops = shopConfigMapper.selectActiveShops();
for (ShopConfig shop : activeShops) {
try {
customerSyncService.syncCustomers(shop.getId());
orderSyncService.syncOrders(shop.getId());
} catch (Exception e) {
log.error("Scheduled sync failed for shop: {}", shop.getStoreName(), e);
}
}
}
}
```
#### 6.4.3 失败重试机制
使用 Spring Retry 实现失败重试:
```java
@Service
public class RobustSyncService {
@Retryable(
value = {ApiException.class, HttpClientErrorException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2)
)
public void syncWithRetry(Long shopConfigId, String syncType) {
switch (syncType) {
case "products":
productSyncService.syncProducts(shopConfigId);
break;
case "customers":
customerSyncService.syncCustomers(shopConfigId);
break;
case "orders":
orderSyncService.syncOrders(shopConfigId);
break;
default:
throw new IllegalArgumentException("Unknown sync type: " + syncType);
}
}
@Recover
public void recoverFromSyncFailure(Exception e, Long shopConfigId, String syncType) {
log.error("Sync failed after retries: shop={}, type={}", shopConfigId, syncType, e);
// 记录失败日志,发送告警
alertService.sendAlert("Data sync failed",
String.format("Shop: %d, Type: %s, Error: %s", shopConfigId, syncType, e.getMessage()));
}
}
```
---
## 7. Webhook 集成
### 7.1 Webhook 概述
Webhook 是店匠平台的事件通知机制,当店铺发生特定事件(如商品更新、订单创建)时,店匠会主动向你注册的 Webhook 地址发送 HTTP POST 请求,实现实时数据同步。
**优势:**
- ✅ 实时性:事件发生后立即通知
- ✅ 减少 API 调用:避免频繁轮询
- ✅ 精准更新:只更新变化的数据
### 7.2 支持的 Webhook Topic
店匠支持以下 Webhook 事件类型:
#### 7.2.1 商品相关
| Topic | 说明 | 触发时机 |
|-------|------|----------|
| `products/create` | 商品创建 | 商家创建新商品时 |
| `products/update` | 商品更新 | 商家修改商品信息时 |
| `products/delete` | 商品删除 | 商家删除商品时 |
#### 7.2.2 订单相关
| Topic | 说明 | 触发时机 |
|-------|------|----------|
| `orders/create` | 订单创建 | 买家下单时 |
| `orders/updated` | 订单更新 | 订单状态变化时 |
| `orders/paid` | 订单支付 | 订单支付成功时 |
| `orders/cancelled` | 订单取消 | 订单被取消时 |
#### 7.2.3 客户相关
| Topic | 说明 | 触发时机 |
|-------|------|----------|
| `customers/create` | 客户创建 | 新客户注册时 |
| `customers/update` | 客户更新 | 客户信息更新时 |
| `customers/delete` | 客户删除 | 客户被删除时 |
### 7.3 注册 Webhook
#### 7.3.1 API 调用
店铺激活后,自动注册所需的 Webhook:
```bash
curl --request POST \
--url 'https://47167113-1.myshoplaza.com/openapi/2022-01/webhooks' \
--header 'access-token: V2WDYgkTvrN68QCESZ9eHb3EjpR6EBrPyAKe-m_JwYY' \
--header 'accept: application/json' \
--header 'content-type: application/json' \
--data '{
"address": "https://your-domain.com/webhook/shoplazza",
"topic": "products/update"
}'
```
**响应示例:**
```json
{
"webhook": {
"id": "123456",
"address": "https://your-domain.com/webhook/shoplazza",
"topic": "products/update",
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z"
}
}
```
#### 7.3.2 批量注册实现
```java
@Service
public class WebhookService {
private static final List WEBHOOK_TOPICS = Arrays.asList(
"products/create",
"products/update",
"products/delete",
"orders/create",
"orders/updated",
"orders/paid",
"customers/create",
"customers/update"
);
/**
* 为店铺注册所有 Webhook
*/
public void registerWebhooks(Long shopConfigId) {
ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
if (shop == null) {
throw new BusinessException("Shop not found");
}
String webhookUrl = buildWebhookUrl(shop.getStoreId());
for (String topic : WEBHOOK_TOPICS) {
try {
registerSingleWebhook(shop, webhookUrl, topic);
log.info("Registered webhook for shop: {}, topic: {}", shop.getStoreName(), topic);
} catch (Exception e) {
log.error("Failed to register webhook: shop={}, topic={}", shop.getStoreName(), topic, e);
// 继续注册其他 Webhook
}
}
}
private void registerSingleWebhook(ShopConfig shop, String webhookUrl, String topic) {
String endpoint = String.format(
"https://%s/openapi/2022-01/webhooks",
shop.getStoreDomain()
);
WebhookRequest request = new WebhookRequest();
request.setAddress(webhookUrl);
request.setTopic(topic);
apiClient.post(shop.getStoreId(), endpoint, request, WebhookResponse.class);
}
private String buildWebhookUrl(String storeId) {
return String.format("%s/webhook/shoplazza/%s",
appConfig.getBaseUrl(),
storeId);
}
}
```
### 7.4 接收和处理 Webhook
#### 7.4.1 Webhook 请求格式
店匠发送的 Webhook 请求格式:
```http
POST /webhook/shoplazza/{store_id}
Content-Type: application/json
X-Shoplazza-Hmac-Sha256: {signature}
X-Shoplazza-Topic: products/update
X-Shoplazza-Shop-Domain: 47167113-1.myshoplaza.com
{
"id": "193817395",
"title": "蓝牙耳机",
"variants": [...],
"images": [...],
...
}
```
**请求头说明:**
- `X-Shoplazza-Hmac-Sha256`:HMAC-SHA256 签名(用于验证请求真实性)
- `X-Shoplazza-Topic`:事件类型
- `X-Shoplazza-Shop-Domain`:店铺域名
#### 7.4.2 签名验证
为了确保 Webhook 请求来自店匠平台,需要验证签名:
```java
@RestController
@RequestMapping("/webhook/shoplazza")
public class WebhookController {
@Autowired
private WebhookService webhookService;
@PostMapping("/{storeId}")
public ResponseEntity handleWebhook(
@PathVariable String storeId,
@RequestHeader("X-Shoplazza-Hmac-Sha256") String signature,
@RequestHeader("X-Shoplazza-Topic") String topic,
@RequestHeader("X-Shoplazza-Shop-Domain") String shopDomain,
@RequestBody String payload) {
try {
// 1. 验证签名
if (!webhookService.verifySignature(storeId, payload, signature)) {
log.warn("Invalid webhook signature: store={}, topic={}", storeId, topic);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid signature");
}
// 2. 处理事件(异步)
webhookService.processWebhookAsync(storeId, topic, payload);
// 3. 立即返回 200(店匠要求3秒内响应)
return ResponseEntity.ok("OK");
} catch (Exception e) {
log.error("Failed to handle webhook: store={}, topic={}", storeId, topic, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error");
}
}
}
@Service
public class WebhookService {
/**
* 验证 Webhook 签名
*/
public boolean verifySignature(String storeId, String payload, String signature) {
ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
if (shop == null) {
return false;
}
// 使用 Client Secret 作为签名密钥
String clientSecret = appConfig.getClientSecret();
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
clientSecret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
mac.init(secretKey);
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
String computedSignature = Base64.getEncoder().encodeToString(hash);
return computedSignature.equals(signature);
} catch (Exception e) {
log.error("Failed to verify signature", e);
return false;
}
}
/**
* 异步处理 Webhook 事件
*/
@Async
public void processWebhookAsync(String storeId, String topic, String payload) {
try {
log.info("Processing webhook: store={}, topic={}", storeId, topic);
switch (topic) {
case "products/create":
case "products/update":
handleProductUpdate(storeId, payload);
break;
case "products/delete":
handleProductDelete(storeId, payload);
break;
case "orders/create":
case "orders/updated":
case "orders/paid":
handleOrderUpdate(storeId, payload);
break;
case "orders/cancelled":
handleOrderCancel(storeId, payload);
break;
case "customers/create":
case "customers/update":
handleCustomerUpdate(storeId, payload);
break;
case "customers/delete":
handleCustomerDelete(storeId, payload);
break;
default:
log.warn("Unknown webhook topic: {}", topic);
}
} catch (Exception e) {
log.error("Failed to process webhook: store={}, topic={}", storeId, topic, e);
}
}
private void handleProductUpdate(String storeId, String payload) {
ProductDto product = JSON.parseObject(payload, ProductDto.class);
ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
// 更新数据库
productSyncService.saveProduct(shop.getTenantId(), storeId, product);
// 更新 ES 索引
esIndexService.indexSingleProduct(shop.getTenantId(), product.getId());
}
private void handleProductDelete(String storeId, String payload) {
ProductDto product = JSON.parseObject(payload, ProductDto.class);
ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
// 软删除数据库记录
productSpuMapper.softDeleteByProductId(storeId, product.getId());
// 从 ES 中删除
esIndexService.deleteProduct(shop.getTenantId(), product.getId());
}
// ... 其他事件处理方法
}
```
### 7.5 幂等性保证
为了避免重复处理同一个事件,需要实现幂等性:
```java
@Service
public class WebhookEventService {
@Autowired
private RedisTemplate redisTemplate;
/**
* 检查事件是否已处理(使用 Redis 去重)
*/
public boolean isEventProcessed(String storeId, String topic, String eventId) {
String key = String.format("webhook:processed:%s:%s:%s", storeId, topic, eventId);
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
/**
* 标记事件已处理(保留24小时)
*/
public void markEventProcessed(String storeId, String topic, String eventId) {
String key = String.format("webhook:processed:%s:%s:%s", storeId, topic, eventId);
redisTemplate.opsForValue().set(key, "1", 24, TimeUnit.HOURS);
}
/**
* 处理事件(带幂等性保证)
*/
@Transactional
public void processEventIdempotent(String storeId, String topic, String eventId, Runnable handler) {
// 检查是否已处理
if (isEventProcessed(storeId, topic, eventId)) {
log.info("Event already processed: store={}, topic={}, eventId={}", storeId, topic, eventId);
return;
}
// 处理事件
handler.run();
// 标记已处理
markEventProcessed(storeId, topic, eventId);
}
}
```
---
## 8. Elasticsearch 索引
### 8.1 索引结构设计
基于店匠商品结构,设计 Elasticsearch mapping:
```json
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"chinese_ecommerce": {
"type": "custom",
"tokenizer": "ik_max_word",
"filter": ["lowercase"]
}
}
}
},
"mappings": {
"properties": {
"tenant_id": {
"type": "keyword"
},
"store_id": {
"type": "keyword"
},
"product_id": {
"type": "keyword"
},
"title": {
"type": "text",
"analyzer": "chinese_ecommerce",
"fields": {
"keyword": {
"type": "keyword"
},
"en": {
"type": "text",
"analyzer": "english"
}
}
},
"title_embedding": {
"type": "dense_vector",
"dims": 1024,
"index": true,
"similarity": "cosine"
},
"body_html": {
"type": "text",
"analyzer": "chinese_ecommerce"
},
"vendor": {
"type": "keyword"
},
"product_type": {
"type": "keyword"
},
"tags": {
"type": "keyword"
},
"price": {
"type": "float"
},
"compare_at_price": {
"type": "float"
},
"inventory_quantity": {
"type": "integer"
},
"image_url": {
"type": "keyword",
"index": false
},
"image_embedding": {
"type": "dense_vector",
"dims": 1024,
"index": true,
"similarity": "cosine"
},
"variants": {
"type": "nested",
"properties": {
"variant_id": {"type": "keyword"},
"sku": {"type": "keyword"},
"title": {"type": "text", "analyzer": "chinese_ecommerce"},
"price": {"type": "float"},
"inventory_quantity": {"type": "integer"},
"option1": {"type": "keyword"},
"option2": {"type": "keyword"},
"option3": {"type": "keyword"}
}
},
"status": {
"type": "keyword"
},
"created_at": {
"type": "date"
},
"updated_at": {
"type": "date"
}
}
}
}
```
### 8.2 索引命名规范
使用租户隔离的索引命名:
```
shoplazza_products_{tenant_id}
```
例如:
- `shoplazza_products_1`
- `shoplazza_products_2`
### 8.3 数据索引流程
#### 8.3.1 从数据库读取商品
```java
@Service
public class EsIndexService {
@Autowired
private ProductSpuMapper spuMapper;
@Autowired
private ProductSkuMapper skuMapper;
@Autowired
private ProductImageMapper imageMapper;
@Autowired
private EmbeddingService embeddingService;
@Autowired
private RestHighLevelClient esClient;
/**
* 为店铺的所有商品建立索引
*/
public void indexProducts(Long shopConfigId) {
ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
if (shop == null) {
throw new BusinessException("Shop not found");
}
String indexName = String.format("shoplazza_products_%d", shop.getTenantId());
// 1. 创建索引(如果不存在)
createIndexIfNotExists(indexName);
// 2. 查询所有商品
List products = spuMapper.selectByStoreId(shop.getStoreId());
// 3. 批量索引
BulkRequest bulkRequest = new BulkRequest();
for (ProductSpu spu : products) {
try {
// 构建 ES 文档
Map doc = buildEsDocument(shop.getTenantId(), spu);
// 添加到批量请求
IndexRequest indexRequest = new IndexRequest(indexName)
.id(spu.getProductId())
.source(doc);
bulkRequest.add(indexRequest);
// 每500条提交一次
if (bulkRequest.numberOfActions() >= 500) {
BulkResponse bulkResponse = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
if (bulkResponse.hasFailures()) {
log.error("Bulk index has failures: {}", bulkResponse.buildFailureMessage());
}
bulkRequest = new BulkRequest();
}
} catch (Exception e) {
log.error("Failed to index product: {}", spu.getProductId(), e);
}
}
// 4. 提交剩余的文档
if (bulkRequest.numberOfActions() > 0) {
BulkResponse bulkResponse = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
if (bulkResponse.hasFailures()) {
log.error("Bulk index has failures: {}", bulkResponse.buildFailureMessage());
}
}
log.info("Indexed {} products for shop: {}", products.size(), shop.getStoreName());
}
/**
* 构建 ES 文档
*/
private Map buildEsDocument(Long tenantId, ProductSpu spu) {
Map doc = new HashMap<>();
// 基本字段
doc.put("tenant_id", tenantId.toString());
doc.put("store_id", spu.getStoreId());
doc.put("product_id", spu.getProductId());
doc.put("title", spu.getTitle());
doc.put("body_html", spu.getBodyHtml());
doc.put("vendor", spu.getVendor());
doc.put("product_type", spu.getProductType());
doc.put("status", spu.getStatus());
doc.put("created_at", spu.getCreatedAt());
doc.put("updated_at", spu.getUpdatedAt());
// 标签
if (StringUtils.isNotEmpty(spu.getTags())) {
doc.put("tags", Arrays.asList(spu.getTags().split(",")));
}
// 变体(SKU)
List skus = skuMapper.selectByProductId(spu.getProductId());
if (CollectionUtils.isNotEmpty(skus)) {
List