Files

617 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 商品导入(创建/更新)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) —— 发布集成方案