mcpField
M
marketplace-catalog-sync

marketplace-catalog-sync

github.com/vtex开发471.0k 次安装2 周前更新
代码仓库

关于 marketplace-catalog-sync

面向 VTEX 外部卖家的目录集成技能,指导实现 SKU 通知、建议审批以及价格和库存同步,并强调正确路由、限流与状态处理。

主要功能

  1. 01区分卖家 SKU 与市场 SKU 路由
  2. 02实现 SKU 建议与审批生命周期
  3. 03同步价格和库存变更
  4. 04处理 429 限流与重试
  5. 05满足履约模拟时限要求

适用场景

  1. 01构建卖家目录连接器
  2. 02注册新 SKU 并跟踪审批
  3. 03向市场通知价格和库存变化

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/vtex/skills --skill marketplace-catalog-sync

安装后在 Claude Code 中输入/marketplace-catalog-sync确认已加载。

详细介绍

目录与 SKU 集成

何时使用此技能

构建卖家连接器时,如果需要将产品目录数据推送到 VTEX marketplace、处理 SKU 审核工作流,或保持价格与库存同步,请使用此技能。

  • 构建用于注册和更新 SKU 的变更通知流程
  • 实现 SKU 建议生命周期(发送 → 待处理 → 已批准/已拒绝)
  • 将产品数据映射到 VTEX 目录架构
  • 通过通知端点同步价格与库存

请勿将此技能用于:

  • Marketplace 端的目录操作(直接写入 Catalog API)
  • 订单履行或发票处理(请参阅 marketplace-fulfillment)
  • 单独处理限流模式(请参阅 marketplace-rate-limiting)

决策规则

VTEX 在 api/catalog_system/pvt/skuseller/changenotification 下提供了两个 POST 路由。它们不能互换使用——路径形式决定了平台如何识别你提供的标识符。

路由路径模式含义
带 marketplace SKU ID 的变更通知.../changenotification/{skuId}{skuId} 是marketplace 目录中的 SKU ID(VTEX)。URL 中没有 sellerId。
带卖家 ID 和卖家 SKU ID 的变更通知.../changenotification/{sellerId}/{skuId}{sellerId} 是 marketplace 上的卖家账户;{skuId} 是卖家自己的 SKU 代码(与 PUT SKU Suggestion 路径中使用的 ID 相同)。

卖家连接器集成必须使用第二个路由。官方文档有时会混淆这两个路由的描述——应以URL 形式为准:卖家范围的流程在 changenotification/ 后始终使用两个路径段。

SKU Suggestion(PUT/GET)必须发送到 https://api.vtex.com/{accountName}/suggestions/{sellerId}/{sellerSkuId},而不是店铺主机名。App Key 和 App Token 相同。不要使用 {account}.vtexcommercestable.com.br/api/catalog_system/pvt/sku/seller/... 构建 suggestion URL——那是另一套 Catalog System 接口。

  • 对于卖家侧目录集成,使用 POST /api/catalog_system/pvt/skuseller/changenotification/{sellerId}/{sellerSkuId}(marketplace 账户中的卖家 ID + 卖家 SKU ID)。200 OK 表示该 SKU 已存在于该卖家在 marketplace 中的目录中(更新流程);404 Not Found 表示不存在(发送 SKU Suggestion)。不要使用 POST .../changenotification/{skuId} 搭配卖家的 SKU 代码——该单段路由要求提供marketplace SKU ID。
  • 仅当你拥有的标识符是 VTEX marketplace SKU ID 时,才使用 POST .../changenotification/{skuId}(路径中没有卖家段)。
  • 使用 https://api.vtex.com/{accountName} 下的 /suggestions/{sellerId}/{sellerSkuId} 执行 PUT(Send SKU Suggestion),以注册或更新待处理的 suggestion。卖家不拥有目录——每个新 SKU 都必须经过 suggestion/审批工作流。
  • 价格和库存使用单独的通知端点:POST /notificator/{sellerId}/changenotification/{sellerSkuId}/price 和 POST /notificator/{sellerId}/changenotification/{sellerSkuId}/inventory。changenotification/ 后的路径段是卖家 SKU ID(卖家自己的 SKU 代码——与 suggestion 和卖家范围的目录流程中使用的标识符相同),而不是 marketplace VTEX SKU ID。参考文档可能将此路径段标记为 skuId;在卖家连接器集成中,应将其理解为 sellerSkuId。
  • 收到价格/库存通知后,marketplace 会调用卖家的 Fulfillment Simulation 端点(POST /pvt/orderForms/simulation)。此端点必须在 2.5 秒内响应,否则该商品会被视为不可用。
  • 只有处于 “pending” 状态时才能更新 suggestion。获批或被拒后,卖家无法再修改。

架构/数据流:

Seller                          VTEX Marketplace
  │                                    │
  │─── POST changenotification ──────▶│
  │◀── 200 (exists) or 404 (new) ────│
  │                                    │
  │─── PUT Send SKU Suggestion ──────▶│  (if 404)
  │                                    │── Pending in Received SKUs
  │                                    │── Marketplace approves/denies
  │                                    │
  │─── POST price notification ──────▶│
  │◀── POST fulfillment simulation ───│  (marketplace fetches data)
  │─── Response with price/stock ────▶│

硬性限制

约束:使用 SKU 集成 API,而非直接使用 Catalog API

外部卖家 MUST 使用变更通知 + SKU 建议流程来集成 SKU。直接写入 Catalog API(POST /api/catalog/pvt/product 或 POST /api/catalog/pvt/stockkeepingunit)仅用于 marketplace 侧的操作。

原因

卖家不拥有目录。直接写入目录会因 403 Forbidden 而失败,或创建绕过审批流程的孤立条目。建议机制可确保 marketplace 进行质量控制。

检测

如果发现卖家集成直接调用 Catalog API 创建商品/SKU(例如 POST /api/catalog/pvt/product、POST /api/catalog/pvt/stockkeepingunit)→ 提醒应改用 SKU 集成 API。

正确

import axios, { AxiosInstance } from "axios";

interface SkuSuggestion {
  ProductName: string;
  SkuName: string;
  ImageUrl: string;
  ProductDescription: string;
  BrandName: string;
  CategoryFullPath: string;
  EAN: string;
  Height: number;
  Width: number;
  Length: number;
  WeightKg: number;
  SkuSpecifications: Array<{
    FieldName: string;
    FieldValues: string[];
  }>;
}

async function integrateSellerSku(
  client: AxiosInstance,
  marketplaceAccount: string,
  sellerId: string,
  sellerSkuId: string,
  skuData: SkuSuggestion
): Promise<void> {
  const storeBaseUrl = `https://${marketplaceAccount}.vtexcommercestable.com.br`;
  const suggestionUrl = `https://api.vtex.com/${marketplaceAccount}/suggestions/${sellerId}/${sellerSkuId}`;

  // Step 1: Seller-scoped change notification (Catalog API — store host)
  try {
    await client.post(
      `${storeBaseUrl}/api/catalog_system/pvt/skuseller/changenotification/${sellerId}/${sellerSkuId}`
    );
    // 200 OK — SKU exists, marketplace will fetch updates via fulfillment simulation
    console.log(`SKU ${sellerSkuId} exists in marketplace, update triggered`);
  } catch (error: unknown) {
    if (axios.isAxiosError(error) && error.response?.status === 404) {
      // 404 — SKU not found, send suggestion
      console.log(`SKU ${sellerSkuId} not found, sending suggestion`);
      await client.put(suggestionUrl, skuData);
      console.log(`SKU suggestion sent for ${sellerSkuId}`);
    } else {
      throw error;
    }
  }
}

错误

// WRONG: Marketplace-SKU-only path with the seller's SKU code (misroutes the notification).
// .../changenotification/{skuId} expects the VTEX marketplace SKU ID, not sellerSkuId.
await client.post(
  `https://${marketplaceAccount}.vtexcommercestable.com.br/api/catalog_system/pvt/skuseller/changenotification/${sellerSkuId}`
);

// WRONG: SKU Suggestion on the store host + Catalog path — public contract is api.vtex.com + /suggestions/...
await client.put(
  `https://${marketplaceAccount}.vtexcommercestable.com.br/api/catalog_system/pvt/sku/seller/${sellerId}/suggestion/${sellerSkuId}`,
  skuData
);

// WRONG: Seller writing directly to marketplace catalog — bypasses suggestion/approval; expect 403
async function createSkuDirectly(
  client: AxiosInstance,
  marketplaceAccount: string,
  productData: Record<string, unknown>
): Promise<void> {
  // Direct catalog write — sellers don't have permission for this
  await client.post(
    `https://${marketplaceAccount}.vtexcommercestable.com.br/api/catalog/pvt/product`,
    productData
  );
  // Will fail: 403 Forbidden — seller lacks catalog write permissions
  // Will fail: 403 Forbidden — seller lacks catalog write permissions
}

约束:处理目录通知的速率限制

所有目录通知调用 MUST 实现 429 处理,并采用指数退避。批量通知 MUST 经过限流,以遵守 VTEX API 速率限制。

原因

变更通知端点设有速率限制。批量发送通知而不进行限流会触发 429 响应,并暂时阻止卖家访问 API,导致整个集成停滞。

检测

如果发现目录通知调用没有处理 429 或重试逻辑 → STOP 并添加限流。如果发现通过紧密循环发送通知且没有延迟 → 提醒存在速率限制问题。

正确

async function batchNotifySkus(
  client: AxiosInstance,
  baseUrl: string,
  sellerId: string,
  sellerSkuIds: string[],
  concurrency: number = 5,
  delayMs: number = 200
): Promise<void> {
  const results: Array<{ sellerSkuId: string; status: "exists" | "new" | "error" }> = [];

  for (let i = 0; i < sellerSkuIds.length; i += concurrency) {
    const batch = sellerSkuIds.slice(i, i + concurrency);

    const batchResults = await Promise.allSettled(
      batch.map(async (sellerSkuId) => {
        try {
          await client.post(
            `${baseUrl}/api/catalog_system/pvt/skuseller/changenotification/${sellerId}/${sellerSkuId}`
          );
          return { sellerSkuId, status: "exists" as const };
        } catch (error: unknown) {
          if (
            error instanceof Error &&
            "response" in error &&
            (error as { response?: { status?: number } }).response?.status === 404
          ) {
            return { sellerSkuId, status: "new" as const };
          }
          if (
            error instanceof Error &&
            "response" in error &&
            (error as { response?: { status?: number; headers?: Record<string, string> } })
              .response?.status === 429
          ) {
            const retryAfter = parseInt(
              (error as { response: { headers: Record<string, string> } }).response.headers[
                "retry-after"
              ] || "60",
              10
            );
            console.warn(`Rate limited. Waiting ${retryAfter}s before retry.`);
            await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
            return { sellerSkuId, status: "error" as const };
          }
          throw error;
        }
      })
    );

    for (const result of batchResults) {
      if (result.status === "fulfilled") {
        results.push(result.value);
      }
    }

    // Throttle between batches
    if (i + concurrency < sellerSkuIds.length) {
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }
  }
}

错误

// WRONG: No rate limiting, no error handling, tight loop
async function notifyAllSkus(
  client: AxiosInstance,
  baseUrl: string,
  sellerId: string,
  sellerSkuIds: string[]
): Promise<void> {
  // Fires all requests simultaneously — will trigger 429 rate limits
  await Promise.all(
    sellerSkuIds.map((sellerSkuId) =>
      client.post(
        `${baseUrl}/api/catalog_system/pvt/skuseller/changenotification/${sellerId}/${sellerSkuId}`
      )
    )
  );
}

约束:处理建议生命周期状态

卖家 MUST 在尝试更新前检查建议状态。只有处于待处理状态的建议才能更新。

原因

尝试更新已批准或已拒绝的建议会静默失败,或创建重复条目。建议获批后将成为 marketplace 所有的 SKU。

检测

如果发现更新 SKU 建议时没有检查当前建议状态 → 提醒需要处理建议生命周期。

正确

async function updateSkuSuggestion(
  client: AxiosInstance,
  marketplaceAccount: string,
  sellerId: string,
  sellerSkuId: string,
  updatedData: Record<string, unknown>
): Promise<boolean> {
  const suggestionUrl = `https://api.vtex.com/${marketplaceAccount}/suggestions/${sellerId}/${sellerSkuId}`;

  // Check current suggestion status before updating
  try {
    const response = await client.get(suggestionUrl);

    const suggestion = response.data;
    if (suggestion.Status === "Pending") {
      // Safe to update — suggestion hasn't been processed yet
      await client.put(suggestionUrl, updatedData);
      return true;
    }

    // Already approved or denied — cannot update
    console.warn(
      `SKU ${sellerSkuId} suggestion is ${suggestion.Status}, cannot update. ` +
        `Use changenotification to update existing SKUs.`
    );
    return false;
  } catch {
    // Suggestion may not exist — send as new
    await client.put(suggestionUrl, updatedData);
    return true;
  }
}

错误

// WRONG: Blindly sending suggestion update without checking state
async function blindUpdateSuggestion(
  client: AxiosInstance,
  marketplaceAccount: string,
  sellerId: string,
  sellerSkuId: string,
  data: Record<string, unknown>
): Promise<void> {
  // If the suggestion was already approved, this fails silently
  // or creates a duplicate that confuses the marketplace operator
  await client.put(
    `https://api.vtex.com/${marketplaceAccount}/suggestions/${sellerId}/${sellerSkuId}`,
    data
  );
}

首选模式

设置卖家连接器客户端

创建经过身份验证的 HTTP 客户端,以便与 VTEX marketplace 通信。

import axios, { AxiosInstance } from "axios";

interface SellerConnectorConfig {
  marketplaceAccount: string;
  sellerId: string;
  appKey: string;
  appToken: string;
}

function createMarketplaceClient(config: SellerConnectorConfig): AxiosInstance {
  return axios.create({
    // Catalog System routes (e.g. changenotification) use the store host.
    baseURL: `https://${config.marketplaceAccount}.vtexcommercestable.com.br`,
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
      "X-VTEX-API-AppKey": config.appKey,
      "X-VTEX-API-AppToken": config.appToken,
    },
    timeout: 10000,
  });
}

// Use the same headers for PUT/GET on https://api.vtex.com/{account}/suggestions/...
// (pass a full URL on the same axios instance, or set baseURL to https://api.vtex.com/{account} for suggestion-only calls).
实现变更通知流程

处理 changenotification 端点返回的“已存在”(200)和“新增”(404)两种情况。

interface CatalogNotificationResult {
  skuId: string;
  action: "updated" | "suggestion_sent" | "error";
  error?: string;
}

async function notifyAndSync(
  client: AxiosInstance,
  marketplaceAccount: string,
  sellerId: string,
  sellerSkuId: string,
  skuData: SkuSuggestion
): Promise<CatalogNotificationResult> {
  const suggestionUrl = `https://api.vtex.com/${marketplaceAccount}/suggestions/${sellerId}/${sellerSkuId}`;

  try {
    await client.post(
      `/api/catalog_system/pvt/skuseller/changenotification/${sellerId}/${sellerSkuId}`
    );
    // SKU exists — marketplace will call fulfillment simulation to get updates
    return { skuId: sellerSkuId, action: "updated" };
  } catch (error: unknown) {
    if (axios.isAxiosError(error) && error.response?.status === 404) {
      try {
        await client.put(suggestionUrl, skuData);
        return { skuId: sellerSkuId, action: "suggestion_sent" };
      } catch (suggestionError: unknown) {
        const message = suggestionError instanceof Error ? suggestionError.message : "Unknown error";
        return { skuId: sellerSkuId, action: "error", error: message };
      }
    }

    const message = error instanceof Error ? error.message : "Unknown error";
    return { skuId: sellerSkuId, action: "error", error: message };
  }
}
实现履约模拟端点

通知后,marketplace 会调用卖家侧的此端点,以获取当前价格和库存数据。

import { RequestHandler } from "express";

interface SimulationItem {
  id: string;
  quantity: number;
  seller: string;
}

interface SimulationRequest {
  items: SimulationItem[];
  postalCode?: string;
  country?: string;
}

interface SimulationResponseItem {
  id: string;
  requestIndex: number;
  quantity: number;
  seller: string;
  price: number;
  listPrice: number;
  sellingPrice: number;
  priceValidUntil: string;
  availability: string;
  merchantName: string;
}

const fulfillmentSimulationHandler: RequestHandler = async (req, res) => {
  const { items, postalCode, country }: SimulationRequest = req.body;

  const responseItems: SimulationResponseItem[] = await Promise.all(
    items.map(async (item, index) => {
      // Fetch current price and inventory from your system
      const skuInfo = await getSkuFromLocalCatalog(item.id);

      return {
        id: item.id,
        requestIndex: index,
        quantity: Math.min(item.quantity, skuInfo.availableQuantity),
        seller: item.seller,
        price: skuInfo.price,
        listPrice: skuInfo.listPrice,
        sellingPrice: skuInfo.sellingPrice,
        priceValidUntil: new Date(Date.now() + 3600000).toISOString(),
        availability: skuInfo.availableQuantity > 0 ? "available" : "unavailable",
        merchantName: "sellerAccountName",
      };
    })
  );

  // CRITICAL: Must respond within 2.5 seconds or products show as unavailable
  res.json({
    items: responseItems,
    postalCode: postalCode ?? "",
    country: country ?? "",
  });
};

async function getSkuFromLocalCatalog(skuId: string): Promise<{
  price: number;
  listPrice: number;
  sellingPrice: number;
  availableQuantity: number;
}> {
  // Replace with your actual catalog/inventory lookup
  return {
    price: 9990,
    listPrice: 12990,
    sellingPrice: 9990,
    availableQuantity: 15,
  };
}
通知价格和库存变更

分别发送价格和库存更新通知。URL 中的 {sellerSkuId} 部分是卖家的 SKU 标识符(即你在目录中以及 changenotification/{sellerId}/{sellerSkuId}/建议中使用的同一代码)。除非你的集成明确以此方式作为键,否则不要在此处传入 marketplace 内部的 VTEX SKU ID。

async function notifyPriceChange(
  client: AxiosInstance,
  sellerId: string,
  sellerSkuId: string
): Promise<void> {
  await client.post(
    `/notificator/${sellerId}/changenotification/${sellerSkuId}/price`
  );
}

async function notifyInventoryChange(
  client: AxiosInstance,
  sellerId: string,
  sellerSkuId: string
): Promise<void> {
  await client.post(
    `/notificator/${sellerId}/changenotification/${sellerSkuId}/inventory`
  );
}

async function syncPriceAndInventory(
  client: AxiosInstance,
  sellerId: string,
  sellerSkuIds: string[]
): Promise<void> {
  for (const sellerSkuId of sellerSkuIds) {
    await notifyPriceChange(client, sellerId, sellerSkuId);
    await notifyInventoryChange(client, sellerId, sellerSkuId);

    // Throttle to avoid rate limits
    await new Promise((resolve) => setTimeout(resolve, 200));
  }
}
完整示例
import axios from "axios";

async function runCatalogSync(): Promise<void> {
  const config: SellerConnectorConfig = {
    marketplaceAccount: "mymarketplace",
    sellerId: "externalseller01",
    appKey: process.env.VTEX_APP_KEY!,
    appToken: process.env.VTEX_APP_TOKEN!,
  };

  const client = createMarketplaceClient(config);

  // Fetch SKUs that need syncing from your system
  const skusToSync = await getLocalSkusNeedingSync();

  for (const sku of skusToSync) {
    const skuSuggestion: SkuSuggestion = {
      ProductName: sku.productName,
      SkuName: sku.skuName,
      ImageUrl: sku.imageUrl,
      ProductDescription: sku.description,
      BrandName: sku.brand,
      CategoryFullPath: sku.categoryPath,
      EAN: sku.ean,
      Height: sku.height,
      Width: sku.width,
      Length: sku.length,
      WeightKg: sku.weight,
      SkuSpecifications: sku.specifications,
    };

    const result = await notifyAndSync(
      client,
      config.marketplaceAccount,
      config.sellerId,
      sku.sellerSkuId,
      skuSuggestion
    );

    console.log(`SKU ${sku.sellerSkuId}: ${result.action}`);

    // Throttle between SKU operations
    await new Promise((resolve) => setTimeout(resolve, 200));
  }

  // Sync prices and inventory for all active SKUs (seller SKU IDs)
  const activeSellerSkuIds = skusToSync.map((s) => s.sellerSkuId);
  await syncPriceAndInventory(client, config.sellerId, activeSellerSkuIds);
}

async function getLocalSkusNeedingSync(): Promise<
  Array<{
    sellerSkuId: string;
    productName: string;
    skuName: string;
    imageUrl: string;
    description: string;
    brand: string;
    categoryPath: string;
    ean: string;
    height: number;
    width: number;
    length: number;
    weight: number;
    specifications: Array<{ FieldName: string; FieldValues: string[] }>;
  }>
> {
  // Replace with your actual data source
  return [];
}

常见故障模式

  • 使用卖家的 SKU ID 调用单段变更通知。 POST .../changenotification/{skuId} 会解析市场 SKU ID。传入卖家的目录代码将无法匹配目标 SKU,并导致集成流程中断。卖家连接器必须使用 POST .../changenotification/{sellerId}/{sellerSkuId}。如果公开 API 参考文档在正文中描述了 sellerId,但 URL 中只有一段路径,则应视为文档不一致,并遵循上述路径格式。

  • 在紧密循环中轮询建议状态。 建议审批是一个由市场人工或半自动处理的流程,可能需要几分钟到几天。频繁轮询会浪费 API 配额,还可能触发速率限制,导致整个集成被阻止。请使用定时任务(cron)定期检查建议状态(例如每 15-30 分钟),或实现基于 webhook 的通知系统。

  • 忽略履约模拟超时。 卖家的履约模拟端点会执行复杂的数据库查询或外部 API 调用,导致响应时间超出限制。VTEX 市场最多等待 2.5 秒 获取履约模拟响应。超时后,商品会被视为不可售/未激活,且不会显示在店面或结账页面中。使用内存缓存或 Redis 缓存预先缓存价格和库存数据,并通过事件驱动的更新机制确保模拟端点立即响应。

import { RequestHandler } from "express";

// Correct: Cache-first approach for fast fulfillment simulation
const cachedPriceInventory = new Map<string, {
  price: number;
  listPrice: number;
  sellingPrice: number;
  availableQuantity: number;
  updatedAt: number;
}>();

const fastFulfillmentSimulation: RequestHandler = async (req, res) => {
  const { items } = req.body;

  const responseItems = items.map((item: SimulationItem, index: number) => {
    const cached = cachedPriceInventory.get(item.id);

    if (!cached) {
      return {
        id: item.id,
        requestIndex: index,
        quantity: 0,
        availability: "unavailable",
      };
    }

    return {
      id: item.id,
      requestIndex: index,
      quantity: Math.min(item.quantity, cached.availableQuantity),
      price: cached.price,
      listPrice: cached.listPrice,
      sellingPrice: cached.sellingPrice,
      availability: cached.availableQuantity > 0 ? "available" : "unavailable",
    };
  });

  // Responds in < 50ms from cache
  res.json({ items: responseItems });
};

审核清单

  • 是否使用了变更通知 + SKU 建议流程(而非直接写入 Catalog API)?
  • 目录变更通知是否使用 .../changenotification/{sellerId}/{sellerSkuId}(而非使用卖家 SKU 代码调用单段市场 SKU 路由)?
  • 集成是否处理了 changenotification 返回的 200(已存在)和 404(新建)响应?
  • SKU 建议更新是否受状态检查保护(仅在状态为“待处理”时更新)?
  • PUT/GET 建议请求是否发送到 https://api.vtex.com/{account}/suggestions/{sellerId}/{sellerSkuId},而不是店铺主机名?
  • 批量目录通知是否经过限流,并在遇到 429 时采用指数退避?
  • 履约模拟端点是否能在 2.5 秒内响应?
  • 价格和库存通知是否通过正确的 /notificator/{sellerId}/changenotification/{sellerSkuId}/price|inventory 路径发送(路径中使用卖家 SKU ID)?
  • 占位值(账户名称、卖家 ID、API 密钥)是否已替换为实际值?

参考

评论

支持 Markdown