SHOPLAZZA_INTEGRATION_GUIDE.md 91.9 KB

店匠平台技术对接指南

1. 概述

1.1 店匠平台介绍

店匠(Shoplazza) 是一个专为跨境电商设计的独立站建站平台,类似于 Shopify。商家可以快速搭建自己的品牌独立站,进行商品销售、订单管理、客户管理等运营。

店匠提供了开放的应用生态系统,第三方开发者可以开发应用插件(APP)并发布到店匠应用市场,为商家提供增值服务。

核心特性:

  • 独立站建站和主题装修
  • 商品、订单、客户管理
  • 多语言和多货币支持
  • 开放的 Admin API
  • Webhook 事件通知
  • OAuth 2.0 授权机制

1.2 对接目标

本文档旨在帮助开发团队将搜索 SaaS 接入店匠生态,作为应用市场的搜索插件上线。

对接目标:

  1. 在店匠应用市场发布搜索 APP
  2. 商家可以安装 APP 并授权访问店铺数据
  3. 自动同步商家的商品、订单、客户数据
  4. 提供前端搜索扩展,嵌入商家的店铺主题
  5. 为商家提供智能搜索服务(多语言、语义搜索、AI 搜索)
  6. 统计分析商家的搜索行为数据

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 前置条件

在开始对接之前,请确保:

  1. ✅ 已注册店匠 Partner 账号
  2. ✅ 拥有公网域名和 HTTPS 证书
  3. ✅ 已部署搜索 SaaS 后端服务
  4. ✅ 拥有测试店铺(用于开发和调试)
  5. ✅ 熟悉 OAuth 2.0 授权流程
  6. ✅ 熟悉 RESTful API 开发

2. 开发者准备

2.1 注册店匠 Partner 账号

  1. 访问 店匠合作伙伴中心
  2. 点击"注册"按钮,填写公司信息
  3. 完成邮箱验证和资质审核
  4. 登录 Partner 后台

2.2 创建 APP 应用

  1. 登录 店匠 Partner 后台
  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 配置

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_tenant 读取客户信息 ✅ 必需
read_app_proxy APP 代理访问 ✅ 必需
write_cart_transform 购物车转换(如需价格调整) ❌ 可选

配置示例:

Scopes: []string{
    "read_shop",
    "read_product",
    "read_order",
    "read_tenant",
    "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)流程:

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_tenant",
        "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_tenant 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:店铺 ID
  • store_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 时相同。

刷新策略:

  1. 在 Token 过期前 7 天开始尝试刷新
  2. API 调用返回 401 Unauthorized 时立即刷新
  3. 刷新成功后更新数据库中的 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}:资源路径,例如 productsorders

示例:

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/tenants?page=1&limit=50

# 获取客户详情
GET /openapi/2022-01/tenants/{tenant_id}

# 获取客户总数
GET /openapi/2022-01/tenants/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

处理策略:

  1. 解析响应头中的速率限制信息
  2. 如果 X-RateLimit-Remaining 为 0,等待到 X-RateLimit-Reset 时间
  3. 收到 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_tenant):

CREATE TABLE `shoplazza_tenant` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
  `tenant_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_tenant` (`store_id`, `tenant_id`),
  KEY `idx_tenant_id` (`tenant_id`),
  KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠客户表';

客户地址表(shoplazza_tenant_address):

CREATE TABLE `shoplazza_tenant_address` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `tenant_id` BIGINT NOT NULL COMMENT '租户ID',
  `store_id` VARCHAR(64) NOT NULL COMMENT '店铺ID',
  `tenant_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_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店匠客户地址表';

6.2.2 API 调用示例

curl --request GET \
  --url 'https://47167113-1.myshoplaza.com/openapi/2022-01/tenants?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 '订单号',
  `tenant_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_tenant_id` (`tenant_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. 同步客户
            tenantSyncService.syncTenants(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 syncTenantsAndOrdersDaily() {
        List<ShopConfig> activeShops = shopConfigMapper.selectActiveShops();

        for (ShopConfig shop : activeShops) {
            try {
                tenantSyncService.syncTenants(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 "tenants":
                tenantSyncService.syncTenants(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 说明 触发时机
tenants/create 客户创建 新客户注册时
tenants/update 客户更新 客户信息更新时
tenants/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",
        "tenants/create",
        "tenants/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 "tenants/create":
                case "tenants/update":
                    handleTenantUpdate(storeId, payload);
                    break;
                case "tenants/delete":
                    handleTenantDelete(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_1
  • shoplazza_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.setTenant("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):
    # 根据 tenant 参数确定租户 ID
    tenant_id = extract_tenant_id(request.tenant)

    # 使用租户专属索引
    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',
  `tenant_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.setTenantId(request.getTenantId());
            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'],
          tenant: `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 主题装修集成

商家可以在店铺后台的主题装修中添加搜索扩展:

  1. 进入店铺后台 → 主题 → 装修
  2. 点击"添加卡片"
  3. 选择"APPS"分类
  4. 找到"AI 搜索" APP
  5. 拖拽"搜索框"组件到导航栏或页面顶部
  6. 创建自定义页面"搜索结果",添加"搜索结果"组件
  7. 保存并发布主题

11. 部署和上线

11.1 域名和 SSL 配置

11.1.1 域名申请

申请一个公网域名,例如:

saas-ai-api.example.com

11.1.2 SSL 证书配置

使用 Let's Encrypt 或其他 CA 颁发的 SSL 证书:

# 使用 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 提交审核

  1. 在 Partner 后台点击"提交审核"
  2. 填写审核说明
  3. 等待审核结果(通常 3-7 个工作日)

11.3.2 审核常见问题

店匠应用审核的常见拒绝原因:

  1. 功能问题:

    • 核心功能无法正常使用
    • 页面加载速度过慢
    • 移动端适配不良
  2. 权限问题:

    • 申请了不必要的权限
    • 未说明权限用途
  3. UI/UX 问题:

    • 界面与店铺风格不一致
    • 缺少多语言支持
    • 操作流程不清晰
  4. 文档问题:

    • 缺少必要的文档
    • 文档描述不清楚
    • 测试账号无法访问

11.3.3 应用发布

审核通过后:

  1. 应用自动发布到店匠应用市场
  2. 商家可以搜索并安装你的 APP
  3. 开始正式运营和推广

12. 附录

12.1 API 参考

12.1.1 店匠 API 端点速查表

API 端点 方法 说明
OAuth
授权 URL /partner/oauth/authorize GET 获取授权
获取 Token /partner/oauth/token POST 换取 Token
商品
商品列表 /openapi/2022-01/products GET 获取商品列表
商品详情 /openapi/2022-01/products/{id} GET 获取单个商品
商品总数 /openapi/2022-01/products/count GET 获取商品总数
订单
订单列表 /openapi/2022-01/orders GET 获取订单列表
订单详情 /openapi/2022-01/orders/{id} GET 获取单个订单
客户
客户列表 /openapi/2022-01/tenants GET 获取客户列表
客户详情 /openapi/2022-01/tenants/{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",
    "tenant": "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",
    "tenant": "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_tenant - 客户表
  • shoplazza_tenant_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_tenant
      - read_app_proxy

  # Webhook 配置
  webhook:
    base-url: https://your-domain.com/webhook/shoplazza
    topics:
      - products/create
      - products/update
      - products/delete
      - orders/create
      - tenants/create

# 搜索服务配置
search:
  service:
    url: http://localhost:6002
    timeout: 30000

# 数据同步配置
sync:
  enabled: true
  batch-size: 50
  schedule:
    products: "0 0 */1 * * ?"  # 每小时
    orders: "0 0 3 * * ?"       # 每天凌晨3点
    tenants: "0 0 4 * * ?"    # 每天凌晨4点

13. 参考资料

13.1 官方文档

13.2 技术栈文档