# 商品导入(创建/更新)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. 请求体结构 ### 完整示例 ```json { "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) ```json { "result": { "task_id": 123456789 } } ``` | 字段 | 类型 | 说明 | |---|---|---| | task_id | integer | **任务 ID**(用于轮询状态,见下节) | ⚠️ **此时商品尚未创建**,需轮询 `/v1/product/import/info` 获取最终结果。 --- ## 4. 轮询任务状态 ### 接口信息 | 项 | 值 | |---|---| | 方法 | POST | | 路径 | `/v1/product/import/info` | | 鉴权 | 需要 `Client-Id` + `Api-Key` | ### 请求 ```json { "task_id": 123456789 } ``` ### 响应 ```json { "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` 数组,展示错误给用户 | ### 错误结构 ```json { "offer_id": "MY-THERMOS-001", "product_id": 0, "status": "failed", "errors": [ { "code": "INVALID_ATTRIBUTE", "message": "Attribute 'Бренд' is required", "field": "attributes" } ] } ``` --- ## 5. 轮询策略 ### 推荐策略 ```python 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"} ``` ### 后台轮询(推荐) ```python # 用户提交发布后立即返回,后台协程轮询 # 状态变化时通知前端(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 更新 ### 创建新商品 ```json { "offer_id": "NEW-PRODUCT-001", // 全新 offer_id // ... 其他字段 } ``` - 如果 `offer_id` 不存在 → 创建新商品 - 如果 `offer_id` 已存在 → 返回 409 冲突 ### 更新已有商品 ```json { "offer_id": "EXISTING-001", // 已存在的 offer_id // ... 要更新的字段(可部分更新) } ``` 或使用 `product_id`: ```json { "product_id": 987654321, // Ozon 商品 ID // ... 要更新的字段 } ``` ⚠️ **注意**: - 更新时,未传的字段**保持原值**(非清空) - 图片数组传空 `[]` 会清空图片(需小心) - 建议更新前先读取当前值(`/v3/product/info/list`) --- ## 7. 批量导入 单次请求最多 **100 个 item**: ```json { "items": [ { "offer_id": "PROD-001", /* ... */ }, { "offer_id": "PROD-002", /* ... */ }, // ... 最多 100 个 ] } ``` 响应包含每个 item 的状态: ```json { "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 ```json { "errors": [ { "code": "DIMENSION_REQUIRED", "message": "Dimensions must be greater than 0", "field": "weight" } ] } ``` **解决**:确保 `depth/width/height/weight` 都 > 0。 #### 错误 2:缺少必填属性 ```json { "errors": [ { "code": "INVALID_ATTRIBUTE", "message": "Required attribute 'Бренд' (id=85) is missing", "field": "attributes" } ] } ``` **解决**:补充缺失的必填属性。 #### 错误 3:图片 URL 不可访问 ```json { "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. 发布后操作 ### 设置库存(必须) 商品导入成功后**不会自动上架**,需设置库存才能开售: ```http POST /v2/products/stocks ``` ```json { "stocks": [ { "product_id": 987654321, "offer_id": "MY-THERMOS-001", "stock": 100, "warehouse_id": 12345678 } ] } ``` ⚠️ 不设置库存 → 商品在后台但不可购买。 ### 查询商品详情 ```http POST /v3/product/info/list ``` ```json { "offer_id": ["MY-THERMOS-001"] } ``` 返回商品完整信息(含审核状态、图片、属性)。 --- ## 10. V2 项目集成 ### API 层(已实现) ```python # 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 [] } ``` ### 前端(待实现) ```tsx // 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 (