617 lines
15 KiB
Markdown
617 lines
15 KiB
Markdown
# 商品导入(创建/更新)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 (
|
||
<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](./02-category-tree.md) —— 获取类目
|
||
- [03-category-attributes.md](./03-category-attributes.md) —— 获取属性
|
||
- [05-product-info.md](./05-product-info.md) —— 查询商品详情
|
||
- [09-stocks.md](./09-stocks.md) —— 设置库存(必须)
|
||
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) —— 发布集成方案
|