Files
ozon-seller-kit/docs/ozon-seller-api/04-product-import.md
T

15 KiB
Raw Blame History

商品导入(创建/更新)API

官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3


接口信息

方法 POST
路径 /v3/product/import
鉴权 需要 Client-Id + Api-Key
用途 创建或更新商品Ozon 核心接口)
异步 返回 task_id,需轮询 /v1/product/import/info 获取最终状态

1. 请求体结构

完整示例

{
  "items": [
    {
      "offer_id": "MY-THERMOS-001",
      "name": "Термокружка Thermos из нержавеющей стали 500 мл",
      "description": "Термокружка из высококачественной нержавеющей стали. Сохраняет температуру до 6 часов.",
      "description_category_id": 17033876,
      "type_id": 97114,
      "price": "2990",
      "old_price": "3490",
      "currency_code": "RUB",
      "vat": "0",
      "depth": 80,
      "width": 80,
      "height": 200,
      "dimension_unit": "mm",
      "weight": 320,
      "weight_unit": "g",
      "images": [
        "https://cdn.example.com/thermos-main-1.jpg",
        "https://cdn.example.com/thermos-main-2.jpg"
      ],
      "primary_image": "",
      "images360": [],
      "color_image": "",
      "barcode": "",
      "attributes": [
        {
          "complex_id": 0,
          "id": 85,
          "values": [
            {
              "dictionary_value_id": 971082156,
              "value": "Thermos"
            }
          ]
        },
        {
          "complex_id": 0,
          "id": 8505,
          "values": [
            {
              "value": "Нержавеющая сталь"
            }
          ]
        }
      ],
      "complex_attributes": []
    }
  ]
}

2. 字段说明

基本信息

字段 类型 必填 说明
offer_id string 自己的货号(唯一标识,用于更新)。最长 255 字符
name string 商品名称。最长 500 字符
description string 商品描述。最长 5000 字符,支持 HTML 标签
description_category_id integer 类目 ID(从类目树获取)
type_id integer 商品类型 ID(从类目树获取)

价格

字段 类型 必填 说明
price string 销售价(字符串格式,如 "2990" = 2990 卢布)
old_price string 可选 划线价(原价),用于展示折扣
currency_code string 币种,通常 "RUB"(卢布)。也可 "CNY"
vat string 增值税率:"0" / "0.1" / "0.2"。俄罗斯默认 "0"

尺寸与重量

字段 类型 必填 说明
depth number 长度(包装尺寸)。不能为 0
width number 宽度(包装尺寸)。不能为 0
height number 高度(包装尺寸)。不能为 0
dimension_unit string 尺寸单位:"mm" / "cm"
weight number 重量(包装重量)。不能为 0
weight_unit string 重量单位:"g" / "kg"

⚠️ 硬约束:尺寸和重量必须 > 0,否则 API 返回 400 错误。

图片

字段 类型 必填 说明
images array 主图数组(最多 15 张)。顺序即展示顺序,第一张为主图
primary_image string 可选 主图(单独指定)。若使用则 images 最多 14 张
images360 array 可选 360° 图片数组
color_image string 可选 营销色图(部分类目支持)

⚠️ 硬约束

  • 图片 URL 必须是 https 公网直链http 会被拒绝)
  • 图片需可访问(Ozon 服务器会主动拉取)
  • 建议尺寸:≥ 700×700 px,白底,主体占画面 80%+

属性

字段 类型 必填 说明
attributes array 商品属性数组(从类目属性获取)
attributes[].complex_id integer 复杂属性 ID,通常填 0
attributes[].id integer 属性 ID
attributes[].values array 属性值数组
values[].dictionary_value_id integer 条件 字典值 ID(属性有字典时必填)
values[].value string 属性值文本

复杂属性

字段 类型 必填 说明
complex_attributes array 可选 复杂属性(视频、尺码表等)

其他

字段 类型 必填 说明
barcode string 可选 条形码
pdf_list array 可选 PDF 文件 URL 列表

3. 响应

成功响应(200

{
  "result": {
    "task_id": 123456789
  }
}
字段 类型 说明
task_id integer 任务 ID(用于轮询状态,见下节)

⚠️ 此时商品尚未创建,需轮询 /v1/product/import/info 获取最终结果。


4. 轮询任务状态

接口信息

方法 POST
路径 /v1/product/import/info
鉴权 需要 Client-Id + Api-Key

请求

{
  "task_id": 123456789
}

响应

{
  "result": {
    "items": [
      {
        "offer_id": "MY-THERMOS-001",
        "product_id": 987654321,
        "status": "imported",
        "errors": []
      }
    ]
  }
}

状态值

status 含义 处理
imported 导入成功 保存 product_id,标记商品为 published
pending 排队中 继续轮询(间隔 5s
processing 处理中 继续轮询(间隔 5s
moderation 审核中 继续轮询(间隔 30s,审核可能需几小时)
failed 失败 读取 errors 数组,展示错误给用户

错误结构

{
  "offer_id": "MY-THERMOS-001",
  "product_id": 0,
  "status": "failed",
  "errors": [
    {
      "code": "INVALID_ATTRIBUTE",
      "message": "Attribute 'Бренд' is required",
      "field": "attributes"
    }
  ]
}

5. 轮询策略

推荐策略

import asyncio

async def wait_for_import(task_id: int, timeout=300):
    """轮询导入状态,最多等待 5 分钟"""
    start = time.time()
    interval = 5  # 初始间隔 5s
    
    while time.time() - start < timeout:
        resp = await ozon_client.get_import_info(task_id)
        item = resp["result"]["items"][0]
        status = item["status"]
        
        if status == "imported":
            return {"success": True, "product_id": item["product_id"]}
        
        if status == "failed":
            return {"success": False, "errors": item["errors"]}
        
        if status == "moderation":
            interval = 30  # 审核阶段降低频率
        
        await asyncio.sleep(interval)
    
    # 超时:不算失败,标记为"审核中"继续后台轮询
    return {"success": None, "status": "timeout"}

后台轮询(推荐)

# 用户提交发布后立即返回,后台协程轮询
# 状态变化时通知前端(WebSocket / 长轮询 / 前端定时刷新)

async def background_poll_task(task_id: int, product_id: str):
    """后台协程,轮询直到完成或失败"""
    result = await wait_for_import(task_id, timeout=3600)  # 最多 1 小时
    
    # 更新数据库
    await db.execute(
        update(Product)
        .where(Product.id == product_id)
        .values(
            stage="published" if result["success"] else "failed",
            ozon_product_id=result.get("product_id"),
            published_at=datetime.utcnow() if result["success"] else None
        )
    )
    
    # 记录任务结果
    await db.execute(
        update(PublishTask)
        .where(PublishTask.ozon_task_id == task_id)
        .values(
            status=result.get("status"),
            errors=result.get("errors"),
            completed_at=datetime.utcnow()
        )
    )

6. 创建 vs 更新

创建新商品

{
  "offer_id": "NEW-PRODUCT-001",  // 全新 offer_id
  // ... 其他字段
}
  • 如果 offer_id 不存在 → 创建新商品
  • 如果 offer_id 已存在 → 返回 409 冲突

更新已有商品

{
  "offer_id": "EXISTING-001",  // 已存在的 offer_id
  // ... 要更新的字段(可部分更新)
}

或使用 product_id

{
  "product_id": 987654321,  // Ozon 商品 ID
  // ... 要更新的字段
}

⚠️ 注意

  • 更新时,未传的字段保持原值(非清空)
  • 图片数组传空 [] 会清空图片(需小心)
  • 建议更新前先读取当前值(/v3/product/info/list

7. 批量导入

单次请求最多 100 个 item

{
  "items": [
    { "offer_id": "PROD-001", /* ... */ },
    { "offer_id": "PROD-002", /* ... */ },
    // ... 最多 100 个
  ]
}

响应包含每个 item 的状态:

{
  "result": {
    "items": [
      { "offer_id": "PROD-001", "status": "imported", "product_id": 111 },
      { "offer_id": "PROD-002", "status": "failed", "errors": [...] }
    ]
  }
}

8. 常见错误

错误码速查

code message 原因 解决
INVALID_PARAMETER 参数错误 必填字段缺失 / 格式错误 检查字段完整性
INVALID_ATTRIBUTE 属性错误 缺少必填属性 / 字典值不匹配 补全必填属性,校验字典值
INVALID_CATEGORY 类目错误 description_category_id 不存在或已禁用 重新选择类目
INVALID_IMAGE 图片错误 URL 不可访问 / 非 https / 格式不支持 检查图片 URL 有效性
OFFER_ID_DUPLICATE offer_id 重复 该 offer_id 已存在 换一个唯一 offer_id 或走更新
DIMENSION_REQUIRED 尺寸必填 尺寸/重量为 0 或缺失 填写正确尺寸重量
PRICE_INVALID 价格错误 价格 ≤ 0 或格式错误 检查价格字段

典型错误示例

错误 1:尺寸为 0

{
  "errors": [
    {
      "code": "DIMENSION_REQUIRED",
      "message": "Dimensions must be greater than 0",
      "field": "weight"
    }
  ]
}

解决:确保 depth/width/height/weight 都 > 0。

错误 2:缺少必填属性

{
  "errors": [
    {
      "code": "INVALID_ATTRIBUTE",
      "message": "Required attribute 'Бренд' (id=85) is missing",
      "field": "attributes"
    }
  ]
}

解决:补充缺失的必填属性。

错误 3:图片 URL 不可访问

{
  "errors": [
    {
      "code": "INVALID_IMAGE",
      "message": "Image URL is not accessible: https://...",
      "field": "images[0]"
    }
  ]
}

解决

  1. 检查 URL 是 https(非 http
  2. 检查 URL 公网可访问(Ozon 服务器需能拉取)
  3. 检查图片格式(支持 jpg/png/webp

9. 发布后操作

设置库存(必须)

商品导入成功后不会自动上架,需设置库存才能开售:

POST /v2/products/stocks
{
  "stocks": [
    {
      "product_id": 987654321,
      "offer_id": "MY-THERMOS-001",
      "stock": 100,
      "warehouse_id": 12345678
    }
  ]
}

⚠️ 不设置库存 → 商品在后台但不可购买。

查询商品详情

POST /v3/product/info/list
{
  "offer_id": ["MY-THERMOS-001"]
}

返回商品完整信息(含审核状态、图片、属性)。


10. V2 项目集成

API 层(已实现)

# server/api/publish.py

@router.post("/products/{product_id}/publish")
async def publish_product(
    product_id: str,
    shop_id: str = Body(...),
    db: AsyncSession = Depends(get_db)
):
    # 1. 取商品数据
    product = await get_product(db, product_id)
    
    # 2. 校验必填项
    validate_required_fields(product)
    
    # 3. 组装 ImportProductsV3 请求体
    item = build_import_item(product)
    
    # 4. 调用 Ozon API
    shop = await get_shop(db, shop_id)
    resp = await ozon_client.import_products(
        shop.client_id_dec,
        shop.api_key_dec,
        [item]
    )
    
    task_id = resp["result"]["task_id"]
    
    # 5. 记录发布任务
    task = PublishTask(
        product_id=product_id,
        shop_id=shop_id,
        ozon_task_id=task_id,
        status="pending",
        request_payload=item
    )
    db.add(task)
    await db.commit()
    
    # 6. 启动后台轮询
    asyncio.create_task(background_poll_task(task_id, product_id))
    
    return {"task_id": task_id}

def build_import_item(product: Product) -> dict:
    """组装 ImportProductsV3 items[0]"""
    return {
        "offer_id": product.offer_id,
        "name": product.name,
        "description": product.description,
        "description_category_id": product.description_category_id,
        "type_id": product.type_id,
        "price": str(product.price),
        "old_price": str(product.old_price) if product.old_price else "",
        "currency_code": product.currency_code,
        "vat": product.vat,
        "depth": product.depth,
        "width": product.width,
        "height": product.height,
        "dimension_unit": product.dimension_unit,
        "weight": product.weight,
        "weight_unit": product.weight_unit,
        "images": product.images,  # 七牛 URL 数组
        "primary_image": product.primary_image or "",
        "images360": product.images360 or [],
        "color_image": product.color_image or "",
        "barcode": product.barcode or "",
        "attributes": product.attributes or [],
        "complex_attributes": product.complex_attributes or []
    }

前端(待实现)

// studio/src/pages/product/components/PublishPanel.tsx

import { Button, Select, message } from 'antd';

export function PublishPanel({ productId }) {
  const [shops, setShops] = useState([]);
  const [publishing, setPublishing] = useState(false);
  
  const handlePublish = async (shopId) => {
    setPublishing(true);
    try {
      const resp = await fetch(`/api/products/${productId}/publish`, {
        method: 'POST',
        body: JSON.stringify({ shop_id: shopId })
      });
      const data = await resp.json();
      
      message.success('发布任务已提交,轮询中...');
      
      // 轮询状态(或 WebSocket 推送)
      pollPublishStatus(data.task_id);
    } catch (err) {
      message.error(`发布失败: ${err.message}`);
    } finally {
      setPublishing(false);
    }
  };
  
  return (
    <div>
      <Select
        placeholder="选择目标店铺"
        options={shops.map(s => ({ label: s.name, value: s.id }))}
        onChange={handlePublish}
      />
      <Button
        type="primary"
        loading={publishing}
        onClick={() => /* trigger select */}
      >
        发布到 Ozon
      </Button>
    </div>
  );
}

相关文档