记录tenant和token-获取商品信息.md 12.1 KB

让我详细解释这些任务,并查看相关的代码实现:

[3 tools called]

📋 详细任务解释

让我逐一解释这些开发任务:


1️⃣ OAuth 的迁移 - 参考 Go 的实现

任务目标: 将现有的 Go 语言 OAuth 实现迁移到 Java 项目中

具体工作:

// 需要实现的 Java 端点:
GET  /oauth/install?shop={shop_domain}     // 处理APP安装请求
GET  /oauth/callback?code=xxx&shop=xxx     // 处理OAuth授权回调
POST /partner/oauth/token                  // 换取/刷新 Access Token

参考的 Go 代码功能:

  • 生成授权 URL 并重定向
  • 处理授权回调
  • 用 code 换取 Access Token
  • 解析 Token 响应并存储

2️⃣ AccessToken 的存储 - 存储到 shoplazza_shop_config

任务目标: OAuth 成功后,将 Token 信息保存到数据库

示意图:

店匠平台                     搜索SaaS平台
-----------                 ----------------
[应用市场]                  
    ↓
[商家安装APP] --------→ OAuth授权流程
    ↓                       ↓
[商家授权成功] --------→ 【第2项】创建租户+存储Token
                            ↓
                        system_tenant (新建)
                        shoplazza_shop_config (新建)
                        存储 AccessToken 和 RefreshToken
                            ↓
                        【第3项】定时刷新Token

Token 的获取和使用流程

sequenceDiagram
    participant 商家
    participant 店匠
    participant 你的后端
    participant 数据库

    Note over 商家,你的后端: 1. OAuth 授权阶段
    商家->>店匠: 安装 APP
    店匠->>你的后端: 跳转授权
    商家->>店匠: 同意授权
    店匠->>你的后端: 回调带 code
    你的后端->>店匠: 用 code 换 Token
    店匠->>你的后端: 返回 Access Token
    你的后端->>数据库: 存储到 shoplazza_shop_config

    Note over 你的后端,数据库: 2. 注册 Webhook 阶段
    你的后端->>数据库: 读取 Access Token
    你的后端->>店匠: 注册 Webhook (带 Access Token)
    店匠->>你的后端: Webhook 注册成功

核心逻辑:

@Transactional
public void handleOAuthCallback(TokenResponse tokenResponse) {
    // 1. 检查租户是否存在,不存在则创建
    Tenant tenant = tenantMapper.selectByStoreId(storeId);
    if (tenant == null) {
        tenant = new Tenant();
        tenant.setName(storeName);
        tenantMapper.insert(tenant);  // 👈 创建新租户
    }

    // 2. 创建或更新店铺配置
    ShopConfig shop = shopConfigMapper.selectByStoreId(storeId);
    if (shop == null) {
        shop = new ShopConfig();
        shop.setTenantId(tenant.getId());
        shop.setStoreId(storeId);
        shop.setStoreName(storeName);
    }

    // 3. 保存 Token 信息
    shop.setAccessToken(tokenResponse.getAccessToken());      // 👈 存储
    shop.setRefreshToken(tokenResponse.getRefreshToken());    // 👈 存储
    shop.setTokenExpiresAt(tokenResponse.getExpiresAt());     // 👈 存储
    shop.setLocale(tokenResponse.getLocale());
    shop.setStatus("active");

    shopConfigMapper.insertOrUpdate(shop);
}

数据表: shoplazza_shop_config(已设计在文档第4章)

📊 token数据库表关系

-- shoplazza_shop_config 表中存储的数据
+----------+----------------+----------------------------------------+
| store_id | store_name     | access_token                           |
+----------+----------------+----------------------------------------+
| 2286274  | 47167113-1     | V2WDYgkTvrN68QCESZ9eHb3EjpR6EB...     |  👈 OAuth时保存
+----------+----------------+----------------------------------------+
                                     
                              注册 Webhook 时读取使用

🔐 Token 的两种用途

这个 Access Token 在你的系统中有两大用途:

  1. 拉取数据 - 调用店匠 Admin API

    • 拉取商品:GET /openapi/2022-01/products
    • 拉取订单:GET /openapi/2022-01/orders
    • 拉取客户:GET /openapi/2022-01/tenants
  2. 注册 Webhook - 让店匠主动推送数据变更

    • 注册:POST /openapi/2022-01/webhooks(需要 Token)
    • 接收:店匠推送到你的 /webhook/shoplazza/{storeId} 端点(不需要 Token)

⚠️ 注意事项

// 注册 Webhook 前,确保 Token 有效
public void registerWebhooks(Long shopConfigId) {
    ShopConfig shop = shopConfigMapper.selectById(shopConfigId);

    // 检查 Token 是否过期
    if (shop.getTokenExpiresAt().before(new Date())) {
        // Token 已过期,先刷新
        tokenService.refreshToken(shop);
        shop = shopConfigMapper.selectById(shopConfigId);  // 重新读取
    }

    // 使用有效的 Token 注册 Webhook
    String accessToken = shop.getAccessToken();
    // ... 注册逻辑
}

3️⃣ RefreshToken 的实现 - 基于定时任务,需考虑对多家店铺的处理

任务目标: 自动刷新即将过期的 Access Token

实现方式:

@Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨2点执行
public void refreshExpiringTokens() {
    // 1. 查询7天内过期的所有店铺
    DateTime sevenDaysLater = DateTime.now().plusDays(7);
    List<ShopConfig> shops = shopConfigMapper.selectExpiringTokens(sevenDaysLater);

    // 2. 遍历每个店铺,刷新 Token
    for (ShopConfig shop : shops) {
        try {
            TokenResponse newToken = oauthClient.refreshToken(
                shop.getRefreshToken(),
                clientId,
                clientSecret
            );

            // 3. 更新数据库中的 Token
            shop.setAccessToken(newToken.getAccessToken());
            shop.setRefreshToken(newToken.getRefreshToken());
            shop.setTokenExpiresAt(newToken.getExpiresAt());
            shopConfigMapper.updateById(shop);

            log.info("Token refreshed for shop: {}", shop.getStoreName());
        } catch (Exception e) {
            log.error("Failed to refresh token for shop: {}", shop.getStoreName(), e);
            // 发送告警通知
        }
    }
}

关键点:

  • ✅ 批量处理多家店铺
  • ✅ 提前7天刷新(避免过期)
  • ✅ 异常处理和告警

4️⃣ 批量拉取商品信息的优化 - 验证分页查询

任务目标: 完善商品数据同步,确保分页正确处理

当前问题: 代码可能只拉取了第一页数据,未正确遍历所有页

需要验证和优化:

public void syncProducts(Long shopConfigId) {
    ShopConfig shop = shopConfigMapper.selectById(shopConfigId);

    int page = 1;
    int limit = 50;
    boolean hasMore = true;

    while (hasMore) {  // 👈 关键:循环直到没有更多数据
        // 调用店匠 API
        String url = String.format(
            "https://%s/openapi/2022-01/products?page=%d&limit=%d",
            shop.getStoreDomain(), page, limit
        );

        ProductListResponse response = apiClient.get(url, shop.getAccessToken());

        // 判断是否还有更多数据
        if (response.getProducts() == null || response.getProducts().isEmpty()) {
            hasMore = false;  // 👈 没有数据了,退出循环
            break;
        }

        // 保存当前页的商品
        for (ProductDto product : response.getProducts()) {
            saveProduct(shop.getTenantId(), shop.getStoreId(), product);
        }

        page++;  // 👈 下一页
        Thread.sleep(100);  // 避免触发速率限制
    }
}

验证要点:

  • ✅ 分页参数正确传递
  • ✅ 循环终止条件正确
  • ✅ 处理空页面情况
  • ✅ 速率限制控制

5️⃣ 批量拉取客户信息的优化 - 验证分页查询

任务目标: 与商品同步类似,完善客户数据同步

实现逻辑:

public void syncTenants(Long shopConfigId) {
    // 与 syncProducts 类似,遍历所有分页
    String url = "https://{shop}/openapi/2022-01/tenants?page={page}&limit=50";

    // 循环拉取所有页
    // 保存到 shoplazza_tenant 和 shoplazza_tenant_address 表
}

6️⃣ 批量拉取订单信息的优化 - 验证分页查询

任务目标: 完善订单数据同步

实现逻辑:

public void syncOrders(Long shopConfigId) {
    String url = "https://{shop}/openapi/2022-01/orders?page={page}&limit=50";

    // 保存到 shoplazza_order 和 shoplazza_order_item 表
}

7️⃣ 批量拉取店铺信息的实现 - 新增实现,需设计对应的数据库表

任务目标: 拉取店铺的详细配置信息

API 调用:

GET /openapi/2022-01/shop

可能的响应字段:

{
  "id": "2286274",
  "name": "47167113-1",
  "domain": "47167113-1.myshoplaza.com",
  "email": "shop@example.com",
  "currency": "USD",
  "timezone": "Asia/Shanghai",
  "locale": "zh-CN",
  "address": {...},
  "phone": "+86 123456789"
}

需要设计的数据表:

CREATE TABLE `shoplazza_shop_info` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `store_id` VARCHAR(64) NOT NULL,
  `shop_name` VARCHAR(255),
  `domain` VARCHAR(255),
  `email` VARCHAR(255),
  `currency` VARCHAR(16),
  `timezone` VARCHAR(64),
  `locale` VARCHAR(16),
  `phone` VARCHAR(64),
  `address` JSON,  -- 存储完整地址信息
  `plan_name` VARCHAR(64),  -- 套餐名称
  `created_at` DATETIME,
  `updated_at` DATETIME,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_store_id` (`store_id`)
) COMMENT='店铺详细信息表';

8️⃣ 注册店铺的 Webhook - 新增实现,需考虑安全验证

任务目标: 为每个店铺注册 Webhook,接收实时数据变更通知

实现步骤:

A. 注册 Webhook(后端主动调用)

@Service
public class WebhookService {

    private static final List<String> WEBHOOK_TOPICS = Arrays.asList(
        "products/create", "products/update", "products/delete",
        "orders/create", "orders/updated", "tenants/create"
    );

    public void registerWebhooks(Long shopConfigId) {
        ShopConfig shop = shopConfigMapper.selectById(shopConfigId);
        String webhookUrl = "https://your-domain.com/webhook/shoplazza/" + shop.getStoreId();

        for (String topic : WEBHOOK_TOPICS) {
            // 调用店匠 API 注册
            apiClient.post(
                "https://" + shop.getStoreDomain() + "/openapi/2022-01/webhooks",
                shop.getAccessToken(),
                Map.of("address", webhookUrl, "topic", topic)
            );
        }
    }
}

B. 接收 Webhook(店匠主动推送)

@RestController
@RequestMapping("/webhook/shoplazza")
public class WebhookController {

    @PostMapping("/{storeId}")
    public ResponseEntity<String> handleWebhook(
            @PathVariable String storeId,
            @RequestHeader("X-Shoplazza-Hmac-Sha256") String signature,  // 👈 安全验证
            @RequestHeader("X-Shoplazza-Topic") String topic,
            @RequestBody String payload) {

        // 1. 验证签名(安全验证)
        if (!verifySignature(payload, signature, clientSecret)) {
            return ResponseEntity.status(401).body("Invalid signature");
        }

        // 2. 异步处理事件
        webhookService.processAsync(storeId, topic, payload);

        // 3. 立即返回 200(店匠要求3秒内响应)
        return ResponseEntity.ok("OK");
    }

    // HMAC-SHA256 签名验证
    private boolean verifySignature(String payload, String signature, String secret) {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
        byte[] hash = mac.doFinal(payload.getBytes());
        String computed = Base64.getEncoder().encodeToString(hash);
        return computed.equals(signature);
    }
}

安全验证关键点:

  • ✅ 使用 HMAC-SHA256 验证签名
  • ✅ 签名密钥使用 APP 的 Client Secret
  • ✅ 3秒内返回响应
  • ✅ 异步处理事件,避免超时