15 KiB
15 KiB
商品导入(创建/更新)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]"
}
]
}
解决:
- 检查 URL 是 https(非 http)
- 检查 URL 公网可访问(Ozon 服务器需能拉取)
- 检查图片格式(支持 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>
);
}
相关文档
- 02-category-tree.md —— 获取类目
- 03-category-attributes.md —— 获取属性
- 05-product-info.md —— 查询商品详情
- 09-stocks.md —— 设置库存(必须)
- docs/v2/ozon-publish.md —— 发布集成方案