feat: 开发采集、采集箱和商品编辑功能

This commit is contained in:
Joey
2026-08-15 22:17:26 +08:00
parent c61d1a3154
commit 36357843d0
130 changed files with 18005 additions and 12 deletions
+616
View File
@@ -0,0 +1,616 @@
# 商品导入(创建/更新)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) —— 发布集成方案