店匠平台技术对接指南
1. 概述
1.1 店匠平台介绍
店匠(Shoplazza) 是一个专为跨境电商设计的独立站建站平台,类似于 Shopify。商家可以快速搭建自己的品牌独立站,进行商品销售、订单管理、客户管理等运营。
店匠提供了开放的应用生态系统,第三方开发者可以开发应用插件(APP)并发布到店匠应用市场,为商家提供增值服务。
核心特性:
- 独立站建站和主题装修
- 商品、订单、客户管理
- 多语言和多货币支持
- 开放的 Admin API
- Webhook 事件通知
- OAuth 2.0 授权机制
1.2 对接目标
本文档旨在帮助开发团队将搜索 SaaS 接入店匠生态,作为应用市场的搜索插件上线。
对接目标:
- 在店匠应用市场发布搜索 APP
- 商家可以安装 APP 并授权访问店铺数据
- 自动同步商家的商品、订单、客户数据
- 提供前端搜索扩展,嵌入商家的店铺主题
- 为商家提供智能搜索服务(多语言、语义搜索、AI 搜索)
- 统计分析商家的搜索行为数据
1.3 系统架构
graph TB
subgraph "店匠平台"
A[店匠应用市场]
B[商家店铺]
C[店匠 Admin API]
D[店匠 Webhook]
end
subgraph "搜索 SaaS 平台"
E[OAuth 服务]
F[数据同步服务]
G[Webhook 接收服务]
H[搜索 API 服务]
I[管理后台]
J[数据库<br/>MySQL]
K[搜索引擎<br/>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 前置条件
在开始对接之前,请确保:
- ✅ 已注册店匠 Partner 账号
- ✅ 拥有公网域名和 HTTPS 证书
- ✅ 已部署搜索 SaaS 后端服务
- ✅ 拥有测试店铺(用于开发和调试)
- ✅ 熟悉 OAuth 2.0 授权流程
- ✅ 熟悉 RESTful API 开发
2. 开发者准备
2.1 注册店匠 Partner 账号
- 访问 店匠合作伙伴中心
- 点击"注册"按钮,填写公司信息
- 完成邮箱验证和资质审核
- 登录 Partner 后台
2.2 创建 APP 应用
- 登录 店匠 Partner 后台
- 在左侧导航栏选择"Apps"
- 点击"Create App"按钮
填写 APP 基本信息:
- App Name:搜索 SaaS(或自定义名称)
- App Type:Public App(公开应用)
- Category:Search & Discovery(搜索与发现)
系统自动生成:
- Client ID:应用的唯一标识
- Client Secret:应用密钥(请妥善保管)
2.3 配置 APP 信息
在 APP 设置页面,配置以下关键信息:
2.3.1 OAuth 配置
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 |
购物车转换(如需价格调整) | ❌ 可选 |
配置示例:
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 准备测试店铺
- 在店匠平台注册一个测试店铺
- 在店铺中添加测试商品、客户、订单数据
- 记录店铺域名:
{shop-name}.myshoplaza.com
注意: 部分功能(如 Webhook 注册)需要店铺激活后才能使用。
3. OAuth 2.0 认证实现
3.1 OAuth 授权流程
店匠使用标准的 OAuth 2.0 授权码(Authorization Code)流程:
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 配置:
// 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}
处理逻辑:
GET /oauth/install
Query Parameters:
- shop: 店铺域名,例如 47167113-1.myshoplaza.com
Response:
302 Redirect to Authorization URL
生成授权 URL:
// 构建授权 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 参数
处理逻辑:
GET /oauth/callback
Query Parameters:
- code: 授权码
- shop: 店铺域名
- state: state 参数
Response:
200 OK (HTML 页面显示安装成功)
3.2.4 换取 Access Token
使用授权码换取 Access Token:
请求示例(curl):
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"
}'
响应示例:
{
"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:店铺 IDstore_name:店铺名称locale:店铺语言
3.2.5 保存 Token 到数据库
将 Token 信息保存到数据库(详见第 4 章数据模型):
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 请求:
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 时相同。
刷新策略:
- 在 Token 过期前 7 天开始尝试刷新
- API 调用返回 401 Unauthorized 时立即刷新
- 刷新成功后更新数据库中的 Token 信息
3.4 安装成功页面
OAuth 回调处理完成后,返回一个 HTML 页面告知商家安装成功:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>安装成功 - 搜索 SaaS</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 50px auto;
text-align: center;
padding: 20px;
}
.success-icon {
font-size: 64px;
color: #52c41a;
margin-bottom: 20px;
}
h1 {
color: #333;
margin-bottom: 10px;
}
p {
color: #666;
line-height: 1.6;
}
.next-steps {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-top: 30px;
text-align: left;
}
.next-steps h2 {
font-size: 18px;
margin-top: 0;
}
.next-steps ol {
margin: 10px 0;
padding-left: 20px;
}
.btn {
display: inline-block;
background: #1890ff;
color: white;
padding: 12px 24px;
border-radius: 4px;
text-decoration: none;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="success-icon">✓</div>
<h1>安装成功!</h1>
<p>搜索 SaaS 已成功安装到您的店铺</p>
<p>店铺名称:<strong>{{store_name}}</strong></p>
<div class="next-steps">
<h2>下一步操作:</h2>
<ol>
<li>进入店铺后台 → 主题装修</li>
<li>点击"添加卡片" → 选择"APPS" → 找到"搜索 SaaS"</li>
<li>拖拽搜索组件到页面中</li>
<li>保存并发布主题</li>
</ol>
</div>
<a href="https://{{shop_domain}}/admin" class="btn">前往店铺后台</a>
</body>
</html>
4. 租户和店铺管理
4.1 数据模型设计
4.1.1 租户表(system_tenant)
每个店铺在 SaaS 平台都是一个独立的租户。
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。
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 自动刷新
public class TokenRefreshService {
/**
* 检查并刷新即将过期的 Token
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void refreshExpiringTokens() {
// 查询7天内过期的 Token
DateTime sevenDaysLater = DateTime.now().plusDays(7);
List<ShopConfig> 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 授权后,自动创建租户和店铺配置:
@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:
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 店铺信息
# 获取店铺详情
GET /openapi/2022-01/shop
5.3.2 商品管理
# 获取商品列表
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 订单管理
# 获取订单列表
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 客户管理
# 获取客户列表
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 使用基于页码的分页:
GET /openapi/2022-01/products?page=1&limit=50&status=active
分页参数:
page:页码,从 1 开始limit:每页数量,最大 250
响应格式:
{
"products": [
{
"id": "123456",
"title": "Product Name",
"variants": [...],
...
}
]
}
5.4.2 错误响应
API 调用失败时返回错误信息:
{
"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 错误处理和重试策略
public class ShoplazzaApiClient {
private static final int MAX_RETRIES = 3;
private static final int RETRY_DELAY_MS = 1000;
/**
* 调用 API 并处理错误
*/
public <T> T callApi(String storeId, String endpoint, Class<T> 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<T> 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
处理策略:
- 解析响应头中的速率限制信息
- 如果
X-RateLimit-Remaining为 0,等待到X-RateLimit-Reset时间 - 收到 429 错误时,使用指数退避重试
6. 数据同步实现
6.1 商品数据同步
6.1.1 API 调用
获取商品列表:
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'
响应示例:
{
"products": [
{
"id": "193817395",
"title": "蓝牙耳机",
"body_html": "<p>高品质蓝牙耳机</p>",
"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):
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):
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):
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 同步逻辑实现
@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):
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):
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 调用示例
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):
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):
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 后,触发首次全量同步:
@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 定时增量同步
配置定时任务,定期同步数据:
@Component
public class ScheduledSyncTask {
@Autowired
private DataSyncService dataSyncService;
@Autowired
private ShopConfigMapper shopConfigMapper;
/**
* 每小时同步一次商品数据
*/
@Scheduled(cron = "0 0 * * * ?")
public void syncProductsHourly() {
List<ShopConfig> 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<ShopConfig> 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 实现失败重试:
@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:
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"
}'
响应示例:
{
"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 批量注册实现
@Service
public class WebhookService {
private static final List<String> 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 请求格式:
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 请求来自店匠平台,需要验证签名:
@RestController
@RequestMapping("/webhook/shoplazza")
public class WebhookController {
@Autowired
private WebhookService webhookService;
@PostMapping("/{storeId}")
public ResponseEntity<String> 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 幂等性保证
为了避免重复处理同一个事件,需要实现幂等性:
@Service
public class WebhookEventService {
@Autowired
private RedisTemplate<String, String> 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:
{
"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_1shoplazza_products_2
8.3 数据索引流程
8.3.1 从数据库读取商品
@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<ProductSpu> products = spuMapper.selectByStoreId(shop.getStoreId());
// 3. 批量索引
BulkRequest bulkRequest = new BulkRequest();
for (ProductSpu spu : products) {
try {
// 构建 ES 文档
Map<String, Object> 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<String, Object> buildEsDocument(Long tenantId, ProductSpu spu) {
Map<String, Object> 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<ProductSku> skus = skuMapper.selectByProductId(spu.getProductId());
if (CollectionUtils.isNotEmpty(skus)) {
List<Map<String, Object>> variants = new ArrayList<>();
for (ProductSku sku : skus) {
Map<String, Object> 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<ProductImage> 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 服务:
@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<String, String> request = new HashMap<>();
request.put("text", text);
ResponseEntity<EmbeddingResponse> 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<String, String> request = new HashMap<>();
request.put("image_url", imageUrl);
ResponseEntity<EmbeddingResponse> 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 触发增量更新:
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<String, Object> 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 搜索服务:
@RestController
@RequestMapping("/api/search")
public class SearchController {
@Autowired
private SearchService searchService;
@PostMapping("/products")
public ResponseEntity<SearchResponse> 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<SearchResponse> 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 搜索服务
@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 日志表设计
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 日志记录实现
@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<SearchStats> getHotQueries(String storeId, int limit) {
return searchLogMapper.selectHotQueries(storeId, limit);
}
/**
* 统计分析:无结果搜索
*/
public List<SearchStats> getNoResultQueries(String storeId, int limit) {
return searchLogMapper.selectNoResultQueries(storeId, limit);
}
}
10. 前端扩展开发
10.1 主题扩展开发
店匠使用 Liquid 模板语言开发主题扩展。
10.1.1 创建扩展项目
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)
<div class="ai-search-box" data-app-block="ai-search">
<form class="search-form" onsubmit="return handleSearch(event)">
<div class="search-input-wrapper">
<input
type="text"
name="q"
class="search-input"
placeholder="{{ 'search.placeholder' | t }}"
autocomplete="off"
/>
<button type="submit" class="search-button">
<svg class="search-icon" viewBox="0 0 24 24">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
</button>
</div>
<div class="search-suggestions" id="search-suggestions"></div>
</form>
</div>
<script>
window.AI_SEARCH_CONFIG = {
storeId: "{{ shop.domain }}",
apiEndpoint: "https://your-domain.com/api/search/products",
locale: "{{ shop.locale }}"
};
</script>
<link rel="stylesheet" href="{{ 'search-box.css' | asset_url }}">
<script src="{{ 'search-box.js' | asset_url }}" defer></script>
10.1.3 搜索框 JavaScript(search-box.js)
// 搜索框功能
(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 => `
<div class="suggestion-item" onclick="selectSuggestion('${item.text}')">
${item.text}
</div>
`).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)
<div class="ai-search-results" data-app-block="ai-search-results">
<div class="search-header">
<h1>{{ 'search.title' | t }}</h1>
<div class="search-query">
{{ 'search.results_for' | t }}: <strong id="current-query"></strong>
</div>
</div>
<div class="search-filters" id="search-filters">
<!-- 动态生成的过滤器 -->
</div>
<div class="search-results-grid" id="search-results">
<div class="loading">{{ 'search.loading' | t }}</div>
</div>
<div class="search-pagination" id="search-pagination"></div>
</div>
<script>
window.AI_SEARCH_CONFIG = {
storeId: "{{ shop.domain }}",
apiEndpoint: "https://your-domain.com/api/search/products",
locale: "{{ shop.locale }}",
currency: "{{ shop.currency }}"
};
</script>
<link rel="stylesheet" href="{{ 'search-results.css' | asset_url }}">
<script src="{{ 'search-results.js' | asset_url }}" defer></script>
10.1.5 搜索结果 JavaScript(search-results.js)
(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 = '<div class="no-results">No products found</div>';
return;
}
const html = results.map(product => `
<div class="product-card">
<a href="/products/${product.handle}" class="product-link">
<div class="product-image">
<img src="${product.image_url || '/assets/placeholder.jpg'}" alt="${product.title}">
</div>
<div class="product-info">
<h3 class="product-title">${product.title}</h3>
<div class="product-vendor">${product.vendor || ''}</div>
<div class="product-price">
${formatPrice(product.price, config.currency)}
</div>
</div>
</a>
</div>
`).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 = '<div class="filters-title">Filters</div>';
for (const [field, values] of Object.entries(facets)) {
if (values.length === 0) continue;
html += `
<div class="filter-group">
<h4 class="filter-title">${formatFieldName(field)}</h4>
<div class="filter-options">
${values.map(item => `
<label class="filter-option">
<input type="checkbox"
value="${item.value}"
onchange="toggleFilter('${field}', '${item.value}')">
<span>${item.value} (${item.count})</span>
</label>
`).join('')}
</div>
</div>
`;
}
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 = '<div class="pagination">';
// 上一页
if (page > 1) {
html += `<a href="?q=${encodeURIComponent(currentQuery)}&page=${page - 1}" class="page-link">Previous</a>`;
}
// 页码
for (let i = Math.max(1, page - 2); i <= Math.min(totalPages, page + 2); i++) {
if (i === page) {
html += `<span class="page-link active">${i}</span>`;
} else {
html += `<a href="?q=${encodeURIComponent(currentQuery)}&page=${i}" class="page-link">${i}</a>`;
}
}
// 下一页
if (page < totalPages) {
html += `<a href="?q=${encodeURIComponent(currentQuery)}&page=${page + 1}" class="page-link">Next</a>`;
}
html += '</div>';
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 = '<div class="loading">Loading...</div>';
}
function showError(message) {
document.getElementById('search-results').innerHTML = `<div class="error">${message}</div>`;
}
// 初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', performSearch);
} else {
performSearch();
}
})();
10.2 多语言支持
10.2.1 中文翻译(locales/zh-CN.json)
{
"search": {
"placeholder": "搜索商品...",
"title": "搜索结果",
"results_for": "搜索",
"loading": "加载中...",
"no_results": "未找到相关商品",
"filters": "筛选",
"clear_filters": "清除筛选"
}
}
10.2.2 英文翻译(locales/en.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 主题装修集成
商家可以在店铺后台的主题装修中添加搜索扩展:
- 进入店铺后台 → 主题 → 装修
- 点击"添加卡片"
- 选择"APPS"分类
- 找到"AI 搜索" APP
- 拖拽"搜索框"组件到导航栏或页面顶部
- 创建自定义页面"搜索结果",添加"搜索结果"组件
- 保存并发布主题
11. 部署和上线
11.1 域名和 SSL 配置
11.1.1 域名申请
申请一个公网域名,例如:
saas-ai-api.example.com
11.1.2 SSL 证书配置
使用 Let's Encrypt 或其他 CA 颁发的 SSL 证书:
# 使用 Certbot 申请证书
sudo apt-get install certbot
sudo certbot certonly --standalone -d saas-ai-api.example.com
11.1.3 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 提交审核
- 在 Partner 后台点击"提交审核"
- 填写审核说明
- 等待审核结果(通常 3-7 个工作日)
11.3.2 审核常见问题
店匠应用审核的常见拒绝原因:
功能问题:
- 核心功能无法正常使用
- 页面加载速度过慢
- 移动端适配不良
权限问题:
- 申请了不必要的权限
- 未说明权限用途
UI/UX 问题:
- 界面与店铺风格不一致
- 缺少多语言支持
- 操作流程不清晰
文档问题:
- 缺少必要的文档
- 文档描述不清楚
- 测试账号无法访问
11.3.3 应用发布
审核通过后:
- 应用自动发布到店匠应用市场
- 商家可以搜索并安装你的 APP
- 开始正式运营和推广
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 请求示例
文本搜索:
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"]
}'
图片搜索:
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 配置
# 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"
解决:
- 检查 Partner 后台配置的 Redirect URI 是否与代码中一致
- 确保 Redirect URI 使用 HTTPS 协议
- 确保 Redirect URI 可公网访问
12.4.2 Token 过期
问题: API 调用返回 401 Unauthorized
解决:
- 检查数据库中的
token_expires_at字段 - 使用 Refresh Token 刷新 Access Token
- 更新数据库中的 Token 信息
12.4.3 API 调用速率限制
问题: API 返回 429 Too Many Requests
解决:
- 降低请求频率
- 实现指数退避重试
- 解析响应头中的
X-RateLimit-Reset字段,等待到指定时间后再重试
12.4.4 Webhook 接收失败
问题: Webhook 事件未收到或签名验证失败
解决:
- 检查 Webhook 地址是否可公网访问
- 检查签名验证逻辑是否正确使用 Client Secret
- 查看店匠后台的 Webhook 日志,确认发送状态
- 确保 Webhook 处理在 3 秒内返回 200 响应
12.4.5 商品搜索无结果
问题: 搜索返回空结果
解决:
- 检查 ES 索引是否存在:
GET /shoplazza_products_1/_count - 检查商品是否已索引:
GET /shoplazza_products_1/_search - 检查租户隔离参数是否正确
- 查看搜索服务日志,确认查询语句
12.4.6 向量生成失败
问题: 图片或文本向量生成失败
解决:
- 检查向量服务是否正常运行
- 检查向量服务的 GPU/CPU 资源是否充足
- 检查图片 URL 是否可访问
- 查看向量服务日志
13. 参考资料
13.1 官方文档
13.2 技术栈文档
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 开发团队