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
+234
View File
@@ -0,0 +1,234 @@
# Ozon Seller API 鉴权与基础
> 官方文档:https://docs.ozon.ru/api/seller/zh/#tag/Introduction
---
## 1. 鉴权方式
Ozon Seller API 使用 **API Key 鉴权**(非 OAuth),每个请求需在请求头携带:
```http
Client-Id: < Client ID>
Api-Key: < API Key>
Content-Type: application/json
```
### 获取凭证
1. 登录 Ozon 卖家后台
2. 进入「设置」→「Seller API」
3. 点击「生成 API Key」
4. 选择权限级别:
- **只读**Read):仅查询
- **读写**Read & Write):查询 + 创建/更新商品
- **管理员**(Admin):所有权限
5. 保存 `Client-Id``Api-Key`**Api-Key 仅显示一次**
### 安全约束
- ⚠️ **Api-Key 等同密码**:泄露后任何人可操作你的店铺
- 🔒 **服务端存储**:加密落库(AES-GCM),前端永不传输/回显明文
- 🔄 **定期轮换**:建议每 90 天更换一次
- 🚫 **前端禁用**:插件/studio 不得持有店铺凭证,只能持有用户 token
---
## 2. Base URL
```
https://api-seller.ozon.ru
```
所有接口路径都基于此 URL,例如:
```
POST https://api-seller.ozon.ru/v3/product/import
```
---
## 3. 请求示例
### cURL
```bash
curl -X POST "https://api-seller.ozon.ru/v1/description-category/tree" \
-H "Client-Id: 123456" \
-H "Api-Key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"language":"RU"}'
```
### Python (httpx)
```python
import httpx
headers = {
"Client-Id": "123456",
"Api-Key": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"Content-Type": "application/json"
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
"https://api-seller.ozon.ru/v1/description-category/tree",
headers=headers,
json={"language": "RU"}
)
data = resp.json()
```
---
## 4. 通用响应结构
### 成功响应(200/201
```json
{
"result": { /* */ }
}
```
部分接口直接返回数组或对象,不包裹 `result`
### 错误响应(4xx/5xx
```json
{
"code": 400,
"message": "INVALID_ARGUMENT",
"details": [
{
"typeUrl": "type.googleapis.com/ozon.ValidationError",
"value": "..."
}
]
}
```
或简化版:
```json
{
"error": {
"code": "INVALID_PARAMETER",
"message": "offer_id is required"
}
}
```
---
## 5. 错误码
| HTTP 状态码 | 含义 | 常见原因 | 处理建议 |
|---|---|---|---|
| **400** | 参数错误 | 必填字段缺失 / 格式错误 / 枚举值非法 | 检查请求体字段,读 `details` 定位 |
| **401** | 未鉴权 | 请求头缺 `Client-Id``Api-Key` | 检查请求头 |
| **403** | 权限不足 | Api-Key 权限级别不够(如只读 key 调创建接口) | 重新生成读写权限 key |
| **404** | 资源不存在 | `product_id` / `category_id` 不存在 | 检查 ID 是否正确 |
| **409** | 资源冲突 | `offer_id` 重复 / 商品已存在 | 改用唯一 offer_id 或走更新接口 |
| **429** | 限流 | 请求频率超限 | 指数退避重试(1s → 2s → 4s) |
| **500** | 服务端错误 | Ozon 内部错误 | 重试 1-2 次,仍失败则联系支持 |
| **503** | 服务不可用 | 维护中 | 稍后重试 |
---
## 6. 限流规则
官方未公开明确的限流阈值,根据社区经验:
- **常规接口**~10 req/s
- **批量接口**(如 `/v3/product/list`):~5 req/s
- **同一 task_id 轮询**:建议间隔 ≥5s
触发 429 后:
1. 解析响应头 `Retry-After`(秒数)
2. 若无此头,使用指数退避:1s → 2s → 4s → 8s
3. 最多重试 3 次
---
## 7. 超时建议
| 接口类型 | 超时时间 | 理由 |
|---|---|---|
| 查询类(类目/属性/商品列表) | 30s | 轻量请求 |
| 导入类(`/v3/product/import` | 60-90s | 后端需校验 + 入库 |
| 轮询状态(`/v1/product/import/info`) | 30s | 单次轮询快,但需多次 |
| 图片上传 | 90s | 网络传输耗时 |
---
## 8. 测试凭证有效性
### `/v1/roles` —— 获取当前 Key 的角色与权限
```http
POST https://api-seller.ozon.ru/v1/roles
```
**请求体**:空 `{}`
**响应**
```json
{
"result": [
{
"role_name": "Seller",
"permissions": [
"read:products",
"write:products",
"read:categories",
...
]
}
]
}
```
**用途**
- ✅ 验证凭证有效性(200 = 有效,401/403 = 无效)
- ✅ 查看权限范围(判断是否有 `write:products`
- ✅ 零业务副作用(不消耗额度,不修改数据)
**V2 集成点**`POST /api/shops/:id/test` 调此接口作连通性校验。
---
## 9. 请求 ID 追踪
部分接口响应包含 `request_id`(如图生图、导入任务),用于:
- 问题排查:联系 Ozon 支持时提供此 ID
- 幂等重试:某些接口可根据 `request_id` 避免重复创建
建议:每次请求在日志里记录 `request_id`(若有)与请求体摘要,便于回溯。
---
## 10. 环境
Ozon Seller API **仅生产环境**,无测试沙箱。调试时需注意:
- ⚠️ 所有操作都在真实店铺
- 💡 建议用「测试商品」标识(如 offer_id 前缀 `TEST-`
- 🗑️ 测试后及时删除/归档测试商品
---
## 11. SDK 与工具
官方未提供 Python SDK,社区方案:
- 自封装 `httpx` 客户端(V2 采用,见 `server/services/ozon_client.py`
- 第三方库:`ozon-api`PyPI,非官方,更新滞后)
---
## 12. 相关链接
- [官方文档(中文)](https://docs.ozon.ru/api/seller/zh/)
- [官方文档(俄文)](https://docs.ozon.ru/api/seller/)
- [卖家后台](https://seller.ozon.ru/)
- [API 状态页](https://status.ozon.ru/)(维护公告)
+333
View File
@@ -0,0 +1,333 @@
# 类目树查询 API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetTree
---
## 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v1/description-category/tree` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 获取 Ozon 商品类目树(选择类目后才能发布商品) |
---
## 请求
### 请求体
```json
{
"language": "RU"
}
```
### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| language | string | 可选 | 语言代码,可选值:`DEFAULT`(英文)、`RU`(俄文)、`EN`(英文)、`ZH_HANS`(简体中文)。默认 `DEFAULT` |
---
## 响应
### 成功响应(200
```json
{
"result": [
{
"description_category_id": 17033876,
"category_name": "Термокружки",
"type_id": 97114,
"type_name": "Термокружка",
"disabled": false,
"children": []
},
{
"description_category_id": 17028922,
"category_name": "Посуда",
"type_id": 0,
"type_name": "",
"disabled": true,
"children": [
{
"description_category_id": 17033876,
"category_name": "Термокружки",
"type_id": 97114,
"type_name": "Термокружка",
"disabled": false,
"children": []
}
]
}
]
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| description_category_id | integer | **类目 ID**(发布商品时必填) |
| category_name | string | 类目名称 |
| type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) |
| type_name | string | 商品类型名称 |
| disabled | boolean | **是否禁用**`true` = 不可建品(父类目),`false` = 可建品(末级类目) |
| children | array | 子类目(递归结构) |
---
## 关键约束
1. **只有末级类目可建品**`disabled=false` 的类目才能用于发布商品
2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id`
3. **层级结构**:类目可能有多层嵌套(最多 5-6 层),需递归遍历找到末级
---
## 使用场景
### 场景 1:前端类目选择器
```
① 请求类目树(language=RU,给俄文用户看)
② 递归展开树形结构
③ 用户选择类目后,校验 disabled=false(若 true 则禁止选择或自动展开子级)
④ 选中后保存 description_category_id + type_id
```
### 场景 2:服务端缓存
```
① 启动时拉取类目树(language=DEFAULT,英文字段名便于代码处理)
② 存入 category_tree 表(见 docs/v2/database.md §2.7
③ TTL 24h,过期重拉
④ 用户选类目时直接查库,不频繁调 API
```
---
## 示例代码
### Python(服务端缓存)
```python
import httpx
from typing import List, Dict, Any
async def fetch_category_tree(
client_id: str,
api_key: str,
language: str = "DEFAULT"
) -> List[Dict[str, Any]]:
"""拉取类目树并返回扁平化列表"""
headers = {
"Client-Id": client_id,
"Api-Key": api_key,
"Content-Type": "application/json"
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
"https://api-seller.ozon.ru/v1/description-category/tree",
headers=headers,
json={"language": language}
)
resp.raise_for_status()
data = resp.json()
# 递归扁平化
def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0):
flat = []
for node in nodes:
flat.append({
"description_category_id": node["description_category_id"],
"parent_id": parent_id,
"category_name": node["category_name"],
"type_id": node["type_id"],
"type_name": node["type_name"],
"disabled": node["disabled"],
"level": level,
"lang": language
})
if node.get("children"):
flat.extend(flatten(
node["children"],
level + 1,
node["description_category_id"]
))
return flat
return flatten(data.get("result", []))
```
### TypeScript(前端选择器)
```typescript
interface CategoryNode {
description_category_id: number;
category_name: string;
type_id: number;
type_name: string;
disabled: boolean;
children: CategoryNode[];
}
async function fetchCategoryTree(language = 'RU'): Promise<CategoryNode[]> {
const resp = await fetch('/api/categories/tree?lang=' + language);
const data = await resp.json();
return data.result;
}
// 转为 antd Tree 数据结构
function toTreeData(nodes: CategoryNode[]): any[] {
return nodes.map(node => ({
key: `${node.description_category_id}-${node.type_id}`,
title: node.category_name,
disabled: node.disabled, // 父类目禁止选择
children: node.children.length > 0 ? toTreeData(node.children) : undefined,
// 保存原始数据,选中时取用
data: {
description_category_id: node.description_category_id,
type_id: node.type_id
}
}));
}
```
---
## 缓存策略
### 全局缓存(推荐)
```python
# 类目树与店铺无关,所有店铺共用一份
# 启动时拉取,存内存 + 数据库
# TTL 24h(类目变化不频繁)
from functools import lru_cache
from datetime import datetime, timedelta
_category_tree_cache = None
_cache_time = None
@lru_cache(maxsize=1)
async def get_category_tree_cached(language: str = "DEFAULT"):
global _category_tree_cache, _cache_time
now = datetime.utcnow()
if _category_tree_cache and _cache_time and (now - _cache_time) < timedelta(hours=24):
return _category_tree_cache
# 从任意店铺拉(类目树全局一致)
tree = await fetch_category_tree(any_client_id, any_api_key, language)
_category_tree_cache = tree
_cache_time = now
# 同时写数据库
await save_to_db(tree)
return tree
```
### 按需更新
```python
# 用户反馈「找不到某类目」时手动刷新
async def refresh_category_tree():
global _category_tree_cache, _cache_time
_category_tree_cache = None
_cache_time = None
get_category_tree_cached.cache_clear()
return await get_category_tree_cached()
```
---
## 常见问题
### Q1: 类目树很大吗?
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。首次拉取需几秒,后续从缓存读取。
### Q2: 多久更新一次?
**A**: Ozon 不定期新增类目(月级别),建议 **24h TTL + 手动刷新入口**
### Q3: 不同语言的类目树结构一样吗?
**A**: 结构一致(`description_category_id` / `type_id` 相同),仅 `category_name` / `type_name` 翻译不同。建议:
- 服务端缓存 `DEFAULT`(英文,便于代码处理)
- 前端按用户语言拉取 `RU`(俄文,展示用)
### Q4: `type_id=0` 是什么意思?
**A**: 父类目(`disabled=true`)的 `type_id` 为 0,表示该节点不是商品类型,只是分类层级。只有末级类目的 `type_id > 0`
---
## V2 项目集成
### API 层(已实现)
```python
# server/api/categories.py
@router.get("/categories/tree")
async def get_tree(
lang: str = Query("RU"),
shop_id: str = Query(...), # 需要店铺凭证
db: AsyncSession = Depends(get_db)
):
shop = await get_shop(db, shop_id)
tree = await ozon_client.get_category_tree(
shop.client_id_dec,
shop.api_key_dec,
lang
)
return {"result": tree}
```
### 前端(待实现)
```tsx
// studio/src/pages/product/components/CategoryPicker.tsx
import { Tree } from 'antd';
import { useEffect, useState } from 'react';
export function CategoryPicker({ shopId, onChange }) {
const [treeData, setTreeData] = useState([]);
useEffect(() => {
fetch(`/api/categories/tree?shop_id=${shopId}&lang=RU`)
.then(r => r.json())
.then(data => setTreeData(toTreeData(data.result)));
}, [shopId]);
return (
<Tree
treeData={treeData}
onSelect={(keys, { node }) => {
if (!node.disabled) {
onChange(node.data); // { description_category_id, type_id }
}
}}
/>
);
}
```
---
## 相关文档
- [03-category-attributes.md](./03-category-attributes.md) —— 获取类目属性
- [docs/v2/database.md](../v2/database.md) §2.7 —— `category_tree` 表结构
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 类目字典缓存策略
@@ -0,0 +1,660 @@
# 类目属性与字典值 API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributes
---
## 1. 获取类目属性
### 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v1/description-category/attribute` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 获取指定类目的所有属性(发布商品时需填写) |
---
### 请求
```json
{
"description_category_id": 17033876,
"type_id": 97114,
"language": "RU"
}
```
#### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| description_category_id | integer | ✅ 是 | 类目 ID(从类目树获取) |
| type_id | integer | ✅ 是 | 商品类型 ID(从类目树获取,与 category_id 配对) |
| language | string | 可选 | 语言代码:`DEFAULT` / `RU` / `EN` / `ZH_HANS`。默认 `DEFAULT` |
---
### 响应
```json
{
"result": [
{
"id": 85,
"name": "Бренд",
"description": "Укажите бренд товара",
"type": "String",
"is_collection": false,
"is_required": true,
"is_aspect": false,
"max_value_count": 1,
"dictionary_id": 28732,
"category_dependent": false,
"group_id": 0,
"group_name": ""
},
{
"id": 8229,
"name": "Цвет товара",
"description": "",
"type": "String",
"is_collection": false,
"is_required": false,
"is_aspect": true,
"max_value_count": 1,
"dictionary_id": 61405,
"category_dependent": false,
"group_id": 1,
"group_name": "Варианты"
},
{
"id": 9048,
"name": "Объем",
"description": "Укажите объем в миллилитрах",
"type": "Integer",
"is_collection": false,
"is_required": true,
"is_aspect": false,
"max_value_count": 1,
"dictionary_id": 0,
"category_dependent": false,
"group_id": 2,
"group_name": "Основные"
}
]
}
```
---
### 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| **id** | integer | **属性 ID**(发布时填 `attributes[].id` |
| **name** | string | 属性名称(如"品牌"、"颜色" |
| description | string | 属性说明(填写提示) |
| **type** | string | 值类型:`String` / `Integer` / `Decimal` / `Boolean` / `URL` |
| **is_required** | boolean | **是否必填**`true` = 必须填写,否则发布失败 |
| **is_aspect** | boolean | **是否变体属性**(如颜色/尺码)。`true` = 该属性用于区分 SKU 变体 |
| **is_collection** | boolean | 是否多值。`true` = 可填多个值(如"适用场景:家用,办公" |
| max_value_count | integer | 最多值数量(`is_collection=true` 时有效) |
| **dictionary_id** | integer | **字典 ID**`> 0` = 有预设值字典(需调字典值接口),`0` = 自由输入 |
| category_dependent | boolean | 字典值是否依赖类目(`true` = 不同类目的字典值不同) |
| group_id | integer | 属性分组 ID |
| group_name | string | 属性分组名(如"基本信息"、"变体" |
---
### 关键字段组合
| 组合 | 含义 | 示例 | 填写方式 |
|---|---|---|---|
| `is_required=true` | **必填** | 品牌、尺寸、重量 | 必须有值,否则发布失败 |
| `dictionary_id > 0` | **有字典** | 品牌、颜色、材质 | 值必须从字典选(dictionary_value_id |
| `dictionary_id = 0` | **自由输入** | 商品名、描述、数值 | 直接填文本/数字 |
| `is_aspect=true` | **变体属性** | 颜色、尺码 | 用于区分 SKU(不同颜色 = 不同 SKU) |
| `is_collection=true` | **多值** | 适用场景、材质组成 | 可传数组 `["值1", "值2"]` |
---
## 2. 获取属性值字典
### 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v1/description-category/attribute/values` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 获取属性的预设值字典(`dictionary_id > 0` 的属性) |
---
### 请求
```json
{
"attribute_id": 85,
"description_category_id": 17033876,
"type_id": 97114,
"language": "RU",
"limit": 1000,
"last_value_id": 0
}
```
#### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| attribute_id | integer | ✅ 是 | 属性 ID |
| description_category_id | integer | ✅ 是 | 类目 ID |
| type_id | integer | ✅ 是 | 商品类型 ID |
| language | string | 可选 | 语言代码 |
| limit | integer | 可选 | 每页数量,最大 **5000**,默认 1000 |
| last_value_id | integer | 可选 | 分页游标(上一页最后一个值的 `id`),首页传 0 |
---
### 响应
```json
{
"result": [
{
"id": 971082156,
"value": "Thermos",
"info": "",
"picture": ""
},
{
"id": 971317107,
"value": "Stanley",
"info": "",
"picture": ""
}
],
"has_next": true
}
```
#### 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| **id** | integer | **字典值 ID**(发布时填 `attributes[].values[].dictionary_value_id` |
| **value** | string | 字典值文本(如品牌名"Thermos" |
| info | string | 补充说明 |
| picture | string | 值配图 URL(部分属性有,如颜色) |
| **has_next** | boolean | 是否有下一页(`true` = 用最后一个 `id` 继续分页) |
---
### 分页示例
```python
async def fetch_all_values(attribute_id, category_id, type_id):
all_values = []
last_id = 0
while True:
resp = await client.post(
"https://api-seller.ozon.ru/v1/description-category/attribute/values",
json={
"attribute_id": attribute_id,
"description_category_id": category_id,
"type_id": type_id,
"language": "RU",
"limit": 5000,
"last_value_id": last_id
}
)
data = resp.json()
values = data.get("result", [])
all_values.extend(values)
if not data.get("has_next") or not values:
break
last_id = values[-1]["id"]
return all_values
```
---
## 3. 按关键词搜索属性值
### 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v1/description-category/attribute/values/search` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 模糊搜索字典值(避免拉全量字典) |
---
### 请求
```json
{
"attribute_id": 85,
"description_category_id": 17033876,
"type_id": 97114,
"language": "RU",
"value": "Ther",
"limit": 100
}
```
#### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| attribute_id | integer | ✅ 是 | 属性 ID |
| description_category_id | integer | ✅ 是 | 类目 ID |
| type_id | integer | ✅ 是 | 商品类型 ID |
| language | string | 可选 | 语言代码 |
| **value** | string | ✅ 是 | 搜索关键词(≥2 个字符) |
| limit | integer | 可选 | 返回数量,最大 **100**,默认 50 |
---
### 响应
```json
{
"result": [
{
"id": 971082156,
"value": "Thermos",
"info": "",
"picture": ""
},
{
"id": 971982345,
"value": "Thermocafe",
"info": "",
"picture": ""
}
]
}
```
---
## 4. 属性映射工作流
### 场景:采集来的参数 → Ozon 属性
```
采集原文(raw.params:
[
{ "key": "Материал", "value": "Нержавеющая сталь" },
{ "key": "Объем", "value": "500 мл" },
{ "key": "Бренд", "value": "Thermos" }
]
↓ ① 自动匹配属性名
attributes = [
{ id: 8505, name: "Материал" }, // 材质
{ id: 9048, name: "Объем" }, // 容量
{ id: 85, name: "Бренд" } // 品牌
]
↓ ② 对有字典的属性(dictionary_id > 0),搜索字典值
POST /attribute/values/search {
attribute_id: 85, // 品牌
value: "Thermos"
}
→ { id: 971082156, value: "Thermos" }
↓ ③ 组装最终 attributes
products.attributes = [
{
"complex_id": 0,
"id": 8505,
"values": [{ "value": "Нержавеющая сталь" }] // 材质无字典,直接填
},
{
"complex_id": 0,
"id": 9048,
"values": [{ "value": "500" }] // 容量是数值,提取数字
},
{
"complex_id": 0,
"id": 85,
"values": [{ "dictionary_value_id": 971082156, "value": "Thermos" }] // 品牌有字典
}
]
```
---
## 5. 自动匹配策略
### 策略 A:归一化 + 模糊匹配
```python
import re
from difflib import SequenceMatcher
def normalize(text: str) -> str:
"""归一化:小写 + 去标点 + 词干"""
text = text.lower().strip()
text = re.sub(r'[^\w\s]', '', text)
# 俄文词干化(需 pymorphy2 库)
# text = morph.parse(text)[0].normal_form
return text
def fuzzy_match(采集key: str, 属性列表: list, threshold=0.8):
"""模糊匹配:相似度 > 0.8 即认为匹配"""
norm_key = normalize(采集key)
best = None
best_score = 0
for attr in 属性列表:
norm_name = normalize(attr["name"])
score = SequenceMatcher(None, norm_key, norm_name).ratio()
if score > best_score:
best = attr
best_score = score
return best if best_score >= threshold else None
```
### 策略 B:关键词映射表
```python
# 预定义常见映射(中文采集 key → Ozon 属性 name
KEYWORD_MAP = {
"品牌": ["Бренд", "Brand"],
"材质": ["Материал", "Material"],
"重量": ["Вес", "Weight"],
"尺寸": ["Размер", "Size"],
"颜色": ["Цвет", "Color"],
# ... 补充更多
}
def keyword_match(采集key: str, 属性列表: list):
for cn_key, ru_keys in KEYWORD_MAP.items():
if cn_key in 采集key:
for attr in 属性列表:
if any(rk in attr["name"] for rk in ru_keys):
return attr
return None
```
---
## 6. 必填项校验
### 发布前校验
```python
async def validate_required_attributes(
category_id: int,
type_id: int,
attributes: list
) -> list[str]:
"""返回缺失的必填属性名列表"""
# 获取该类目的所有属性
attrs = await fetch_category_attributes(category_id, type_id)
# 提取必填属性
required = [a for a in attrs if a["is_required"]]
# 已填写的属性 ID
filled_ids = {a["id"] for a in attributes}
# 找出缺失的
missing = [a["name"] for a in required if a["id"] not in filled_ids]
return missing
# 使用
missing = await validate_required_attributes(17033876, 97114, product.attributes)
if missing:
raise ValueError(f"缺少必填属性:{', '.join(missing)}")
```
---
## 7. 缓存策略
### 属性列表缓存
```python
# 按 (category_id, type_id) 缓存
# TTL 7 天(属性变化极少)
from functools import lru_cache
@lru_cache(maxsize=500)
async def get_attributes_cached(category_id: int, type_id: int):
# 先查数据库
cached = await db.query(CategoryAttribute).filter_by(
description_category_id=category_id,
type_id=type_id
).all()
if cached:
return cached
# 未缓存,调 API
attrs = await fetch_category_attributes(category_id, type_id)
# 写入数据库
await save_attributes_to_db(attrs)
return attrs
```
### 字典值缓存(按需)
```python
# 字典值可能很大(数万条),不全量缓存
# 策略:用户映射到某属性时,才拉该属性的字典(且优先用 /search)
async def get_attribute_values(attr_id, category_id, type_id, keyword=None):
if keyword:
# 有关键词 → 搜索接口(limit 100)
return await search_values(attr_id, category_id, type_id, keyword)
else:
# 无关键词 → 拉全量(分页,存数据库)
return await fetch_all_values(attr_id, category_id, type_id)
```
---
## 8. 前端交互设计
### 属性映射 UI(推荐)
```
┌─────────────────────────────────────────────────┐
│ 类目属性映射 │
├─────────────────────────────────────────────────┤
│ 采集属性 → Ozon 属性 │
├─────────────────────────────────────────────────┤
│ ✅ Материал (材质) → [自动] Материал (8505) │
│ 值:Нержавеющая сталь │
├─────────────────────────────────────────────────┤
│ ✅ Бренд (品牌) → [自动] Бренд (85) ⚠️必填 │
│ 值:Thermos → 字典值:[选择 ▼] │
│ ├ Thermos ✅ │
│ ├ Stanley │
│ └ ... │
├─────────────────────────────────────────────────┤
│ ⚠️ 未匹配:包装重量 │
│ → [手动选择属性 ▼] │
├─────────────────────────────────────────────────┤
│ ❌ 缺少必填属性: │
│ - Объем (容量) [+添加] │
│ - Цвет (颜色) [+添加] │
└─────────────────────────────────────────────────┘
```
---
## 9. 常见问题
### Q1: 属性太多怎么办?
**A**: 一个类目可能有 **50-100+ 属性**,但常用的只有 10-20 个。策略:
- 必填属性置顶 + 高亮
- 已匹配属性展开,未匹配折叠
- 提供搜索/筛选
### Q2: 字典值有多大?
**A**:
- 小字典(颜色/材质):几十到几百条
- 大字典(品牌):**数万条**(如品牌字典 > 50,000
- 策略:**优先用 `/values/search`**,避免拉全量
### Q3: 自由输入的属性怎么填?
**A**: `dictionary_id=0` 的属性直接填 `value`,无需 `dictionary_value_id`
```json
{
"id": 9048,
"values": [{ "value": "500" }] // 容量,数值类型
}
```
### Q4: 如何处理多值属性?
**A**: `is_collection=true` 时传数组:
```json
{
"id": 10096,
"values": [
{ "value": "家用" },
{ "value": "办公" }
]
}
```
---
## 10. V2 项目集成
### API 层(已实现)
```python
# server/api/categories.py
@router.get("/categories/{category_id}/attributes")
async def get_attributes(
category_id: int,
type_id: int = Query(...),
shop_id: str = Query(...),
db: AsyncSession = Depends(get_db)
):
shop = await get_shop(db, shop_id)
attrs = await ozon_client.get_category_attributes(
shop.client_id_dec,
shop.api_key_dec,
category_id,
type_id
)
return {"result": attrs}
@router.get("/categories/attribute/{attribute_id}/values")
async def get_attribute_values(
attribute_id: int,
category_id: int = Query(...),
type_id: int = Query(...),
q: str = Query(None), # 搜索关键词
shop_id: str = Query(...),
db: AsyncSession = Depends(get_db)
):
shop = await get_shop(db, shop_id)
if q and len(q) >= 2:
# 搜索接口
values = await ozon_client.search_attribute_values(
shop.client_id_dec, shop.api_key_dec,
attribute_id, category_id, type_id, q
)
else:
# 全量拉取(分页)
values = await ozon_client.get_attribute_values(
shop.client_id_dec, shop.api_key_dec,
attribute_id, category_id, type_id
)
return {"result": values}
```
### 前端(待实现)
```tsx
// studio/src/pages/product/components/AttributeMapper.tsx
import { Form, Select, Input, Tag } from 'antd';
export function AttributeMapper({ categoryId, typeId, rawParams, onChange }) {
const [attributes, setAttributes] = useState([]);
useEffect(() => {
fetch(`/api/categories/${categoryId}/attributes?type_id=${typeId}`)
.then(r => r.json())
.then(data => setAttributes(data.result));
}, [categoryId, typeId]);
// 自动匹配
const autoMatch = () => {
const matched = rawParams.map(p => {
const attr = attributes.find(a =>
normalize(a.name) === normalize(p.key)
);
return attr ? { ...p, attrId: attr.id, attr } : p;
});
return matched;
};
return (
<div>
{autoMatch().map((item, i) => (
<Form.Item
key={i}
label={item.key}
required={item.attr?.is_required}
>
{item.attr?.dictionary_id > 0 ? (
<Select
showSearch
placeholder="选择字典值"
onSearch={(q) => fetchValues(item.attr.id, q)}
/>
) : (
<Input defaultValue={item.value} />
)}
</Form.Item>
))}
</div>
);
}
```
---
## 相关文档
- [02-category-tree.md](./02-category-tree.md) —— 获取类目树
- [04-product-import.md](./04-product-import.md) —— 发布商品(使用属性)
- [docs/v2/database.md](../v2/database.md) §2.7 —— 属性缓存表结构
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 属性映射策略
+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) —— 发布集成方案
+557
View File
@@ -0,0 +1,557 @@
# 商品信息查询 API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetProductInfoListV3
---
## 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v3/product/info/list` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 查询商品详细信息(含审核状态、图片、属性、错误) |
---
## 1. 请求
### 请求体
```json
{
"offer_id": ["MY-THERMOS-001", "MY-THERMOS-002"],
"product_id": [987654321],
"sku": [123456789]
}
```
### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| offer_id | array | 可选 | 自己的货号数组(最多 100 个) |
| product_id | array | 可选 | Ozon 商品 ID 数组(最多 100 个) |
| sku | array | 可选 | Ozon SKU 数组(最多 100 个) |
⚠️ **至少提供一个筛选条件**offer_id / product_id / sku)。
---
## 2. 响应
### 成功响应(200
```json
{
"result": {
"items": [
{
"id": 987654321,
"name": "Термокружка Thermos из нержавеющей стали 500 мл",
"offer_id": "MY-THERMOS-001",
"barcode": "",
"buybox_price": "2990.00",
"category_id": 17033876,
"created_at": "2024-08-10T10:30:00Z",
"images": [
{
"file_name": "thermos-main-1.jpg",
"default": true,
"index": 0
}
],
"marketing_price": "2990.00",
"min_price": "2690.00",
"old_price": "3490.00",
"premium_price": "2790.00",
"price": "2990.00",
"recommended_price": "2990.00",
"sources": [
{
"is_enabled": true,
"sku": 123456789,
"source": "fbs"
}
],
"state": "processed",
"stocks": {
"coming": 0,
"present": 100,
"reserved": 5
},
"errors": [],
"vat": "0.00",
"visible": true,
"visibility_details": {
"has_price": true,
"has_stock": true,
"active_product": true
},
"price_index": "5.0",
"images360": [],
"color_image": "",
"primary_image": "",
"status": {
"state": "processed",
"state_failed": "",
"moderate_status": "approved",
"decline_reasons": [],
"validation_state": "success",
"state_name": "Processed",
"state_description": "Product is processed",
"is_failed": false,
"is_created": true,
"state_tooltip": ""
},
"description_category_id": 17033876,
"type_id": 97114,
"width": 80,
"height": 200,
"depth": 80,
"dimension_unit": "mm",
"weight": 320,
"weight_unit": "g",
"attributes": [
{
"attribute_id": 85,
"complex_id": 0,
"values": [
{
"dictionary_value_id": 971082156,
"value": "Thermos"
}
]
}
]
}
]
}
}
```
---
## 3. 核心字段说明
### 基本信息
| 字段 | 类型 | 说明 |
|---|---|---|
| id | integer | Ozon 商品 ID`product_id` |
| name | string | 商品名称 |
| offer_id | string | 自己的货号 |
| barcode | string | 条形码 |
| created_at | string | 创建时间(ISO 8601 |
### 价格
| 字段 | 类型 | 说明 |
|---|---|---|
| price | string | 当前售价 |
| old_price | string | 划线价(原价) |
| marketing_price | string | 营销价 |
| buybox_price | string | BuyBox 价格(赢得购物车的价格) |
| recommended_price | string | 平台推荐价 |
| min_price | string | 允许的最低价(低于此价需申请) |
| premium_price | string | Premium 会员价 |
### 状态
| 字段 | 类型 | 说明 |
|---|---|---|
| **state** | string | **商品状态**(见下表) |
| **status** | object | **状态详情**(含审核状态、错误原因) |
| visible | boolean | 是否可见(上架) |
| visibility_details | object | 可见性详情(是否有价格/库存/激活) |
#### state 状态值
| state | 含义 | 说明 |
|---|---|---|
| **processed** | ✅ 已处理 | 商品创建成功,可正常展示 |
| **processing** | ⏳ 处理中 | 正在处理(刚导入) |
| **moderating** | ⏳ 审核中 | 平台审核中 |
| **failed** | ❌ 失败 | 创建/审核失败,查看 `errors` |
| **archived** | 📦 已归档 | 商品已下架归档 |
#### status.moderate_status 审核状态
| moderate_status | 含义 |
|---|---|
| **approved** | ✅ 审核通过 |
| **pending** | ⏳ 待审核 |
| **declined** | ❌ 审核拒绝 |
### 库存
| 字段 | 类型 | 说明 |
|---|---|---|
| stocks.present | integer | 当前库存 |
| stocks.reserved | integer | 已预订数量 |
| stocks.coming | integer | 即将到货数量 |
### 图片
| 字段 | 类型 | 说明 |
|---|---|---|
| images | array | 图片数组 |
| images[].file_name | string | 图片文件名 |
| images[].default | boolean | 是否主图 |
| images[].index | integer | 顺序 |
| primary_image | string | 主图 URL |
| images360 | array | 360° 图 |
| color_image | string | 营销色图 |
### 尺寸与属性
| 字段 | 类型 | 说明 |
|---|---|---|
| description_category_id | integer | 类目 ID |
| type_id | integer | 商品类型 ID |
| width / height / depth | number | 尺寸 |
| dimension_unit | string | 尺寸单位 |
| weight | number | 重量 |
| weight_unit | string | 重量单位 |
| attributes | array | 属性数组(结构同导入) |
### 错误信息
| 字段 | 类型 | 说明 |
|---|---|---|
| errors | array | 错误数组(审核失败原因、字段错误等) |
| status.decline_reasons | array | 审核拒绝原因 |
| status.validation_state | string | 校验状态:`success` / `failed` |
---
## 4. 使用场景
### 场景 1:发布后回查 product_id
```python
# 导入后用 offer_id 查询,获取 product_id
async def get_product_id_by_offer(offer_id: str):
resp = await ozon_client.get_product_info(
offer_id=[offer_id]
)
items = resp["result"]["items"]
if items:
return items[0]["id"]
return None
```
### 场景 2:检查审核状态
```python
async def check_moderation_status(product_id: int):
resp = await ozon_client.get_product_info(
product_id=[product_id]
)
item = resp["result"]["items"][0]
status = item["status"]
return {
"state": status["state"],
"moderate_status": status["moderate_status"],
"is_approved": status["moderate_status"] == "approved",
"decline_reasons": status["decline_reasons"]
}
```
### 场景 3:读取审核错误
```python
async def get_product_errors(offer_id: str):
resp = await ozon_client.get_product_info(offer_id=[offer_id])
item = resp["result"]["items"][0]
errors = []
# 字段错误
if item.get("errors"):
errors.extend(item["errors"])
# 审核拒绝原因
if item["status"].get("decline_reasons"):
errors.extend(item["status"]["decline_reasons"])
return errors
```
---
## 5. 错误处理
### 商品不存在
```json
{
"result": {
"items": []
}
}
```
返回空数组,非 404 错误。
### 部分成功
```json
{
"result": {
"items": [
{
"id": 987654321,
"offer_id": "EXISTING-001",
/* ... */
}
]
}
}
```
请求 3 个 offer_id,只有 1 个存在 → 只返回 1 个 item。
---
## 6. 与其他接口的关系
### 与 `/v3/product/import` 的配合
```
① POST /v3/product/import → task_id
② POST /v1/product/import/info → status=imported, product_id=X
③ POST /v3/product/info/list (product_id=X) → 读取完整信息(含图片/审核状态)
```
**用途**:导入后可能需要:
- 确认图片上传成功
- 检查审核状态
- 读取 Ozon 生成的 SKU
- 查看价格索引(`price_index`,影响排名)
### 与 `/v3/product/list` 的区别
| 接口 | 用途 | 返回字段 |
|---|---|---|
| `/v3/product/info/list` | **详情查询** | 完整字段(图片/属性/状态/错误) |
| `/v3/product/list` | **列表分页** | 基本字段(id/name/price/state),支持筛选/排序 |
**选择建议**
- 已知 offer_id/product_id,要完整信息 → 用 `info/list`
- 分页浏览所有商品、筛选状态 → 用 `list`
---
## 7. 示例代码
### Python(服务端)
```python
async def fetch_product_detail(
client_id: str,
api_key: str,
offer_id: str = None,
product_id: int = None
):
"""查询商品详情"""
headers = {
"Client-Id": client_id,
"Api-Key": api_key,
"Content-Type": "application/json"
}
payload = {}
if offer_id:
payload["offer_id"] = [offer_id]
if product_id:
payload["product_id"] = [product_id]
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
"https://api-seller.ozon.ru/v3/product/info/list",
headers=headers,
json=payload
)
resp.raise_for_status()
data = resp.json()
items = data.get("result", {}).get("items", [])
return items[0] if items else None
```
### TypeScript(前端)
```typescript
async function getProductDetail(
productId: string,
by: 'offer_id' | 'product_id' = 'offer_id'
) {
const resp = await fetch('/api/products/detail', {
method: 'POST',
body: JSON.stringify({
[by]: [productId]
})
});
const data = await resp.json();
return data.result.items[0];
}
// 使用
const detail = await getProductDetail('MY-THERMOS-001', 'offer_id');
console.log('审核状态:', detail.status.moderate_status);
console.log('库存:', detail.stocks.present);
```
---
## 8. 审核拒绝原因解读
### 常见拒绝原因
| decline_reason | 含义 | 解决 |
|---|---|---|
| 图片不符合要求 | 图片非白底/有水印/模糊 | 重新上传符合规范的图片 |
| 标题含禁用词 | 标题有夸大宣传/品牌侵权词 | 修改标题,去除违规词 |
| 描述不完整 | 描述过短或缺少关键信息 | 补充完整商品描述 |
| 类目错误 | 商品与类目不匹配 | 重新选择正确类目 |
| 属性缺失 | 缺少必填属性 | 补充必填属性 |
| 品牌未授权 | 品牌需授权认证 | 提供品牌授权书或改用无品牌 |
### 处理流程
```
① 读取 status.decline_reasons
② 根据原因修改商品(改图/改文案/改属性)
③ 重新调用 /v3/product/import(同 offer_id = 更新)
④ 再次审核
```
---
## 9. V2 项目集成
### API 层(待实现)
```python
# server/api/products.py
@router.get("/products/{product_id}/ozon-detail")
async def get_ozon_detail(
product_id: str,
db: AsyncSession = Depends(get_db)
):
"""查询商品在 Ozon 的详情(审核状态/库存/图片)"""
product = await get_product(db, product_id)
if not product.ozon_product_id and not product.offer_id:
raise HTTPException(404, "商品尚未发布到 Ozon")
# 获取店铺凭证(从发布记录找)
task = await db.execute(
select(PublishTask)
.where(PublishTask.product_id == product_id)
.order_by(PublishTask.created_at.desc())
.limit(1)
)
task = task.scalar_one_or_none()
if not task:
raise HTTPException(404, "未找到发布记录")
shop = await get_shop(db, task.shop_id)
# 调用 Ozon API
detail = await ozon_client.get_product_info(
shop.client_id_dec,
shop.api_key_dec,
offer_id=[product.offer_id] if product.offer_id else None,
product_id=[product.ozon_product_id] if product.ozon_product_id else None
)
return {"result": detail}
```
### 前端(商品详情页展示审核状态)
```tsx
// studio/src/pages/product/components/OzonStatusBadge.tsx
import { Badge, Tooltip } from 'antd';
export function OzonStatusBadge({ productId }) {
const [status, setStatus] = useState(null);
useEffect(() => {
fetch(`/api/products/${productId}/ozon-detail`)
.then(r => r.json())
.then(data => {
const item = data.result.items[0];
setStatus(item.status);
});
}, [productId]);
if (!status) return null;
const statusMap = {
approved: { color: 'success', text: '审核通过' },
pending: { color: 'processing', text: '审核中' },
declined: { color: 'error', text: '审核拒绝' }
};
const config = statusMap[status.moderate_status] || {};
return (
<Tooltip title={status.decline_reasons?.join(', ')}>
<Badge status={config.color} text={config.text} />
</Tooltip>
);
}
```
---
## 10. 性能优化
### 批量查询
```python
# 一次查询多个商品(最多 100 个)
async def batch_get_products(offer_ids: list[str]):
resp = await ozon_client.get_product_info(offer_id=offer_ids)
return {
item["offer_id"]: item
for item in resp["result"]["items"]
}
# 使用
details = await batch_get_products([
"PROD-001", "PROD-002", "PROD-003"
])
```
### 缓存策略
```python
# 商品详情变化不频繁,可短期缓存
from functools import lru_cache
@lru_cache(maxsize=1000)
async def get_product_info_cached(offer_id: str, ttl=300):
# TTL 5 分钟
detail = await ozon_client.get_product_info(offer_id=[offer_id])
return detail["result"]["items"][0] if detail["result"]["items"] else None
# 审核状态变化时清缓存
get_product_info_cached.cache_clear()
```
---
## 相关文档
- [04-product-import.md](./04-product-import.md) —— 创建/更新商品
- [06-product-list.md](./06-product-list.md) —— 商品列表分页
- [09-stocks.md](./09-stocks.md) —— 库存管理
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) —— 发布集成方案
+477
View File
@@ -0,0 +1,477 @@
# 商品列表查询 API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetProductListV3
---
## 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v3/product/list` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 分页查询商品列表(支持筛选、排序) |
---
## 1. 请求
### 请求体
```json
{
"filter": {
"offer_id": ["MY-THERMOS-001"],
"product_id": [987654321],
"visibility": "ALL"
},
"last_id": "",
"limit": 100
}
```
### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| filter | object | 可选 | 筛选条件 |
| filter.offer_id | array | 可选 | 货号数组 |
| filter.product_id | array | 可选 | Ozon 商品 ID 数组 |
| filter.visibility | string | 可选 | 可见性:`ALL`(全部)/ `VISIBLE`(可见)/ `INVISIBLE`(不可见)/ `EMPTY_STOCK`(无库存)。默认 `ALL` |
| **last_id** | string | 可选 | **分页游标**(上一页最后一个商品的 ID),首页传空字符串 `""` |
| **limit** | integer | 可选 | 每页数量,最大 **1000**,默认 100 |
---
## 2. 响应
### 成功响应(200
```json
{
"result": {
"items": [
{
"product_id": 987654321,
"offer_id": "MY-THERMOS-001"
},
{
"product_id": 987654322,
"offer_id": "MY-THERMOS-002"
}
],
"total": 256,
"last_id": "bnVtYmVyMjo5ODc2NTQzMjI="
}
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| items | array | 商品列表(**仅基本字段**product_id + offer_id |
| total | integer | 商品总数 |
| **last_id** | string | **下一页游标**(Base64 编码,传给下次请求) |
⚠️ **注意**:此接口**仅返回 product_id 和 offer_id**,不返回名称/价格/图片等详情。要获取完整信息,需再调 `/v3/product/info/list`
---
## 3. 分页示例
### 游标分页(推荐)
```python
async def fetch_all_products(visibility="ALL"):
"""拉取所有商品(游标分页)"""
all_items = []
last_id = ""
while True:
resp = await ozon_client.get_product_list(
filter={"visibility": visibility},
last_id=last_id,
limit=1000 # 单次最多 1000
)
items = resp["result"]["items"]
all_items.extend(items)
# 无更多数据
if not resp["result"].get("last_id"):
break
last_id = resp["result"]["last_id"]
return all_items
```
### 分批处理
```python
async def process_products_in_batches(batch_size=100):
"""分批处理商品(避免一次拉全部)"""
last_id = ""
while True:
resp = await ozon_client.get_product_list(
last_id=last_id,
limit=batch_size
)
items = resp["result"]["items"]
if not items:
break
# 处理当前批次
await process_batch(items)
last_id = resp["result"].get("last_id")
if not last_id:
break
```
---
## 4. 获取完整信息
### 方法 A:批量查详情(推荐)
```python
async def fetch_products_with_detail(visibility="ALL"):
"""拉取商品列表 + 完整信息"""
# 1. 拉列表(仅 ID
list_resp = await ozon_client.get_product_list(
filter={"visibility": visibility},
limit=1000
)
items = list_resp["result"]["items"]
product_ids = [item["product_id"] for item in items]
# 2. 批量查详情(每次最多 100 个)
details = []
for i in range(0, len(product_ids), 100):
batch = product_ids[i:i+100]
detail_resp = await ozon_client.get_product_info(
product_id=batch
)
details.extend(detail_resp["result"]["items"])
return details
```
### 方法 B:按需查详情
```python
# 先列表,用户点击某个商品时再查详情
products = await ozon_client.get_product_list(limit=100)
# 用户点击 product_id=987654321
detail = await ozon_client.get_product_info(product_id=[987654321])
```
---
## 5. 筛选条件详解
### visibility 筛选
| 值 | 含义 | 使用场景 |
|---|---|---|
| **ALL** | 全部商品 | 管理后台(查看所有) |
| **VISIBLE** | 可见商品(上架) | 前台展示的商品 |
| **INVISIBLE** | 不可见商品(下架/草稿) | 待上架/审核失败/归档 |
| **EMPTY_STOCK** | 无库存商品 | 补货提醒 |
### 示例
```python
# 查询所有上架商品
visible = await ozon_client.get_product_list(
filter={"visibility": "VISIBLE"},
limit=1000
)
# 查询无库存商品(需补货)
empty_stock = await ozon_client.get_product_list(
filter={"visibility": "EMPTY_STOCK"},
limit=100
)
```
---
## 6. 性能对比
### `/v3/product/list` vs `/v3/product/info/list`
| 维度 | `/v3/product/list` | `/v3/product/info/list` |
|---|---|---|
| 返回字段 | 仅 product_id + offer_id | 完整字段(图片/属性/状态) |
| 单次数量 | 最多 **1000** | 最多 **100** |
| 响应速度 | 快(字段少) | 慢(字段多) |
| 适用场景 | 列表/分页/ID 收集 | 详情查询/更新前读取 |
**策略**
1. 先用 `/list` 拉 ID 列表(快)
2. 再用 `/info/list` 批量查详情(按需,100 个一批)
---
## 7. 与本地数据库同步
### 场景:回填 product_id
```python
async def sync_product_ids():
"""发布后回填 product_id(用 offer_id 匹配)"""
# 1. 从数据库取所有「已发布但无 product_id」的商品
local_products = await db.execute(
select(Product)
.where(
Product.stage == "published",
Product.ozon_product_id.is_(None),
Product.offer_id.isnot(None)
)
)
local_products = local_products.scalars().all()
if not local_products:
return
offer_ids = [p.offer_id for p in local_products]
# 2. 从 Ozon 查询这些 offer_id 的 product_id
ozon_items = []
for i in range(0, len(offer_ids), 100):
batch = offer_ids[i:i+100]
resp = await ozon_client.get_product_info(offer_id=batch)
ozon_items.extend(resp["result"]["items"])
# 3. 回填到数据库
ozon_map = {item["offer_id"]: item["id"] for item in ozon_items}
for p in local_products:
if p.offer_id in ozon_map:
p.ozon_product_id = ozon_map[p.offer_id]
await db.commit()
```
### 场景:定期同步状态
```python
async def sync_product_states():
"""定期同步商品状态(审核状态/库存/可见性)"""
# 1. 拉取所有 Ozon 商品 ID
ozon_resp = await ozon_client.get_product_list(limit=1000)
ozon_ids = [item["product_id"] for item in ozon_resp["result"]["items"]]
# 2. 批量查详情
details = []
for i in range(0, len(ozon_ids), 100):
batch = ozon_ids[i:i+100]
resp = await ozon_client.get_product_info(product_id=batch)
details.extend(resp["result"]["items"])
# 3. 更新本地数据库
for item in details:
await db.execute(
update(Product)
.where(Product.ozon_product_id == item["id"])
.values(
stage="published" if item["visible"] else "failed",
# 可同步更多字段:价格/库存/审核状态
)
)
await db.commit()
```
---
## 8. 常见问题
### Q1: 为什么 `/list` 只返回 ID
**A**: 性能考虑。商品列表可能有**数万条**,返回完整字段会很慢。设计思路:
1. 先快速拉 ID 列表(轻量)
2. 前端展示分页,只查当前页的详情
3. 或后台批量拉详情,按需处理
### Q2: 如何获取商品总数?
**A**: 响应的 `total` 字段。但注意:
- `total` 是当前筛选条件下的总数
- 不保证精确(可能略有延迟)
### Q3: 游标分页与偏移分页的区别?
**A**:
- **游标分页**(last_id):适合全量遍历,性能稳定
- **偏移分页**offset):Ozon 不支持(无 offset 参数)
### Q4: 多久同步一次?
**A**: 建议策略:
- 发布后立即查询(回填 product_id
- 定期同步(每天一次,更新状态/库存)
- 用户主动刷新(按需实时查询)
---
## 9. V2 项目集成
### API 层(待实现)
```python
# server/api/products.py
@router.get("/products/sync-from-ozon")
async def sync_from_ozon(
shop_id: str = Query(...),
db: AsyncSession = Depends(get_db)
):
"""从 Ozon 同步商品列表(回填 product_id + 状态)"""
shop = await get_shop(db, shop_id)
# 1. 拉取 Ozon 商品列表
ozon_items = []
last_id = ""
while True:
resp = await ozon_client.get_product_list(
shop.client_id_dec,
shop.api_key_dec,
last_id=last_id,
limit=1000
)
items = resp["result"]["items"]
ozon_items.extend(items)
last_id = resp["result"].get("last_id")
if not last_id:
break
# 2. 批量查详情
product_ids = [item["product_id"] for item in ozon_items]
details = []
for i in range(0, len(product_ids), 100):
batch = product_ids[i:i+100]
detail_resp = await ozon_client.get_product_info(
shop.client_id_dec,
shop.api_key_dec,
product_id=batch
)
details.extend(detail_resp["result"]["items"])
# 3. 更新本地数据库
updated = 0
for item in details:
result = await db.execute(
update(Product)
.where(Product.offer_id == item["offer_id"])
.values(
ozon_product_id=item["id"],
stage="published" if item["visible"] else "archived"
)
)
updated += result.rowcount
await db.commit()
return {
"synced": len(details),
"updated": updated
}
```
### 前端(待实现)
```tsx
// studio/src/pages/products/SyncButton.tsx
import { Button, message } from 'antd';
import { SyncOutlined } from '@ant-design/icons';
export function SyncFromOzonButton({ shopId }) {
const [syncing, setSyncing] = useState(false);
const handleSync = async () => {
setSyncing(true);
try {
const resp = await fetch(
`/api/products/sync-from-ozon?shop_id=${shopId}`
);
const data = await resp.json();
message.success(
`已同步 ${data.synced} 个商品,更新 ${data.updated} 条记录`
);
} catch (err) {
message.error(`同步失败: ${err.message}`);
} finally {
setSyncing(false);
}
};
return (
<Button
icon={<SyncOutlined />}
loading={syncing}
onClick={handleSync}
>
Ozon
</Button>
);
}
```
---
## 10. 高级用法
### 增量同步
```python
async def incremental_sync(last_sync_time: datetime):
"""增量同步:只拉取最近更新的商品"""
# Ozon 的 /list 接口不支持按更新时间筛选
# 策略:全量拉 ID,对比本地 updated_at,只查变化的
ozon_items = await fetch_all_products()
ozon_ids = {item["product_id"] for item in ozon_items}
# 查本地已有的 product_id
local = await db.execute(
select(Product.ozon_product_id, Product.updated_at)
.where(Product.ozon_product_id.isnot(None))
)
local_map = {
row.ozon_product_id: row.updated_at
for row in local.fetchall()
}
# 找出新增的 ID
new_ids = ozon_ids - set(local_map.keys())
# 批量查详情(只查新增的)
if new_ids:
details = await batch_get_product_info(list(new_ids))
# 插入数据库
...
```
---
## 相关文档
- [04-product-import.md](./04-product-import.md) —— 创建/更新商品
- [05-product-info.md](./05-product-info.md) —— 查询商品详情
- [09-stocks.md](./09-stocks.md) —— 库存管理
- [docs/v2/database.md](../v2/database.md) —— products 表结构
+599
View File
@@ -0,0 +1,599 @@
# 库存管理 API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ProductsStocksV2
---
## 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v2/products/stocks` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | **设置/更新商品库存**(必须操作,否则商品不可购买) |
---
## 1. 重要约束
⚠️ **商品导入成功后不会自动上架**,必须设置库存才能开售:
```
POST /v3/product/import → status=imported(商品已创建)
POST /v2/products/stocks → 设置库存(商品可购买)
```
未设置库存的商品:
- ✅ 在卖家后台可见
- ❌ 前台不展示
- ❌ 无法购买
---
## 2. 请求
### 请求体
```json
{
"stocks": [
{
"offer_id": "MY-THERMOS-001",
"product_id": 987654321,
"stock": 100,
"warehouse_id": 12345678
},
{
"offer_id": "MY-THERMOS-002",
"stock": 50,
"warehouse_id": 12345678
}
]
}
```
### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| stocks | array | ✅ 是 | 库存数组(最多 **100** 个) |
| stocks[].offer_id | string | 条件 | 自己的货号(与 product_id 二选一) |
| stocks[].product_id | integer | 条件 | Ozon 商品 ID(与 offer_id 二选一) |
| stocks[].stock | integer | ✅ 是 | 库存数量。`0` = 无库存(下架) |
| stocks[].warehouse_id | integer | ✅ 是 | 仓库 ID(见下节) |
⚠️ **必须提供 offer_id 或 product_id**(建议用 offer_id,更稳定)。
---
## 3. 仓库 IDwarehouse_id
### 获取仓库 ID
**接口**`POST /v1/warehouse/list`
```json
{}
```
**响应**
```json
{
"result": [
{
"warehouse_id": 12345678,
"name": "FBS 仓库-莫斯科",
"can_print_act_in_advance": true,
"is_rfbs": false,
"has_postings_limit": false,
"postings_limit": 0,
"status": "working"
}
]
}
```
| 字段 | 说明 |
|---|---|
| warehouse_id | **仓库 ID**(设置库存时用) |
| name | 仓库名称 |
| is_rfbs | 是否 rFBS 仓库(Ozon 代发货) |
| status | 状态:`working`(运行中)/ `disabled`(禁用) |
### 仓库类型
| 类型 | 说明 | warehouse_id |
|---|---|---|
| **FBS** | 自发货(Fulfillment by Seller | 从 `/v1/warehouse/list` 获取 |
| **FBO** | Ozon 发货(Fulfillment by Ozon | 从 `/v1/warehouse/list` 获取 |
| **rFBS** | Ozon 代发货(类似 FBO,但库存在卖家处) | `is_rfbs=true` |
**推荐**:新商户优先用 **FBS**(自发货),灵活且门槛低。
---
## 4. 响应
### 成功响应(200
```json
{
"result": [
{
"errors": [],
"offer_id": "MY-THERMOS-001",
"product_id": 987654321,
"updated": true,
"warehouse_id": 12345678
},
{
"errors": [
{
"code": "PRODUCT_NOT_FOUND",
"message": "Product not found"
}
],
"offer_id": "MY-THERMOS-999",
"product_id": 0,
"updated": false,
"warehouse_id": 12345678
}
]
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| updated | boolean | 是否更新成功 |
| errors | array | 错误数组(失败时) |
| offer_id | string | 货号(回显) |
| product_id | integer | Ozon 商品 ID(回显) |
| warehouse_id | integer | 仓库 ID(回显) |
---
## 5. 常见错误
| code | message | 原因 | 解决 |
|---|---|---|---|
| `PRODUCT_NOT_FOUND` | 商品不存在 | offer_id/product_id 错误或商品已删除 | 检查 ID 是否正确 |
| `WAREHOUSE_NOT_FOUND` | 仓库不存在 | warehouse_id 错误 | 调 `/v1/warehouse/list` 获取正确 ID |
| `INVALID_STOCK` | 库存值错误 | stock < 0 | 库存必须 ≥ 0 |
| `PRODUCT_ARCHIVED` | 商品已归档 | 商品处于归档状态 | 先恢复商品再设库存 |
---
## 6. 使用场景
### 场景 1:发布后设置初始库存
```python
async def publish_and_set_stock(product: Product, shop: Shop):
"""发布商品 + 设置库存(完整流程)"""
# 1. 导入商品
resp = await ozon_client.import_products(
shop.client_id_dec,
shop.api_key_dec,
[build_import_item(product)]
)
task_id = resp["result"]["task_id"]
# 2. 轮询直到成功
result = await wait_for_import(task_id)
if not result["success"]:
raise Exception(f"发布失败: {result['errors']}")
product_id = result["product_id"]
# 3. 获取仓库 ID
warehouses = await ozon_client.get_warehouses(
shop.client_id_dec,
shop.api_key_dec
)
warehouse_id = warehouses[0]["warehouse_id"] # 取第一个
# 4. 设置库存
stock_resp = await ozon_client.update_stocks(
shop.client_id_dec,
shop.api_key_dec,
[{
"product_id": product_id,
"stock": 100, # 初始库存
"warehouse_id": warehouse_id
}]
)
return stock_resp
```
### 场景 2:批量更新库存
```python
async def batch_update_stocks(updates: list[dict]):
"""批量更新库存(最多 100 个)"""
# updates = [
# {"offer_id": "PROD-001", "stock": 50},
# {"offer_id": "PROD-002", "stock": 0}, # 0 = 下架
# ]
warehouse_id = await get_default_warehouse_id()
stocks = [
{
"offer_id": u["offer_id"],
"stock": u["stock"],
"warehouse_id": warehouse_id
}
for u in updates
]
resp = await ozon_client.update_stocks(
client_id, api_key, stocks
)
# 检查失败项
failed = [
item for item in resp["result"]
if not item["updated"]
]
return {
"success": len(resp["result"]) - len(failed),
"failed": failed
}
```
### 场景 3:库存为 0 时下架
```python
async def out_of_stock(offer_id: str):
"""库存售罄,设为 0(自动下架)"""
await ozon_client.update_stocks(
client_id, api_key,
[{
"offer_id": offer_id,
"stock": 0, # 库存为 0 → 前台不展示
"warehouse_id": warehouse_id
}]
)
```
### 场景 4:补货后上架
```python
async def restock(offer_id: str, quantity: int):
"""补货后重新上架"""
await ozon_client.update_stocks(
client_id, api_key,
[{
"offer_id": offer_id,
"stock": quantity, # 设置新库存 → 自动上架
"warehouse_id": warehouse_id
}]
)
```
---
## 7. 查询当前库存
### 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v3/product/info/stocks` |
| 用途 | 查询商品当前库存 |
### 请求
```json
{
"filter": {
"offer_id": ["MY-THERMOS-001"],
"product_id": [987654321],
"visibility": "ALL"
},
"last_id": "",
"limit": 100
}
```
### 响应
```json
{
"result": {
"items": [
{
"offer_id": "MY-THERMOS-001",
"product_id": 987654321,
"stocks": [
{
"type": "fbs",
"present": 100,
"reserved": 5,
"warehouse_id": 12345678,
"warehouse_name": "FBS 仓库-莫斯科"
}
]
}
],
"last_id": "",
"total": 1
}
}
```
| 字段 | 说明 |
|---|---|
| stocks[].present | 可用库存 |
| stocks[].reserved | 已预订数量(订单未完成) |
| stocks[].type | 仓库类型:`fbs` / `fbo` / `rfbs` |
---
## 8. 库存同步策略
### 策略 A:实时同步(推荐)
```python
# 本地库存变化时立即更新 Ozon
async def on_local_stock_change(product_id: str, new_stock: int):
product = await get_product(db, product_id)
if not product.ozon_product_id:
return # 未发布到 Ozon
shop = await get_default_shop(db)
warehouse_id = await get_default_warehouse_id()
await ozon_client.update_stocks(
shop.client_id_dec,
shop.api_key_dec,
[{
"offer_id": product.offer_id,
"stock": new_stock,
"warehouse_id": warehouse_id
}]
)
```
### 策略 B:定时同步
```python
# 每天凌晨同步一次(防止偏差累积)
async def daily_sync_stocks():
"""定时任务:同步本地库存到 Ozon"""
products = await db.execute(
select(Product)
.where(
Product.stage == "published",
Product.ozon_product_id.isnot(None)
)
)
products = products.scalars().all()
warehouse_id = await get_default_warehouse_id()
# 批量更新(100 个一批)
for i in range(0, len(products), 100):
batch = products[i:i+100]
stocks = [
{
"offer_id": p.offer_id,
"stock": p.local_stock, # 假设有 local_stock 字段
"warehouse_id": warehouse_id
}
for p in batch
]
await ozon_client.update_stocks(
client_id, api_key, stocks
)
```
### 策略 C:反向同步(从 Ozon 读回)
```python
# 定期从 Ozon 读回库存(多渠道销售时需要)
async def sync_stocks_from_ozon():
"""从 Ozon 同步库存到本地"""
resp = await ozon_client.get_product_stocks(
filter={"visibility": "VISIBLE"},
limit=1000
)
for item in resp["result"]["items"]:
offer_id = item["offer_id"]
ozon_stock = item["stocks"][0]["present"]
# 更新本地库存
await db.execute(
update(Product)
.where(Product.offer_id == offer_id)
.values(local_stock=ozon_stock)
)
await db.commit()
```
---
## 9. V2 项目集成
### API 层(待实现)
```python
# server/api/products.py
@router.post("/products/{product_id}/set-stock")
async def set_stock(
product_id: str,
stock: int = Body(..., ge=0),
shop_id: str = Body(...),
db: AsyncSession = Depends(get_db)
):
"""设置商品库存"""
product = await get_product(db, product_id)
if not product.ozon_product_id and not product.offer_id:
raise HTTPException(400, "商品尚未发布到 Ozon")
shop = await get_shop(db, shop_id)
# 获取仓库 ID(缓存)
warehouse_id = await get_or_cache_warehouse_id(shop)
# 调用 Ozon API
resp = await ozon_client.update_stocks(
shop.client_id_dec,
shop.api_key_dec,
[{
"offer_id": product.offer_id,
"stock": stock,
"warehouse_id": warehouse_id
}]
)
result = resp["result"][0]
if not result["updated"]:
raise HTTPException(500, f"更新失败: {result['errors']}")
# 更新本地记录
product.local_stock = stock
await db.commit()
return {"success": True, "stock": stock}
@router.get("/shops/{shop_id}/warehouses")
async def get_warehouses(
shop_id: str,
db: AsyncSession = Depends(get_db)
):
"""获取店铺的仓库列表"""
shop = await get_shop(db, shop_id)
warehouses = await ozon_client.get_warehouses(
shop.client_id_dec,
shop.api_key_dec
)
return {"result": warehouses}
```
### 前端(待实现)
```tsx
// studio/src/pages/product/components/StockPanel.tsx
import { InputNumber, Button, message } from 'antd';
export function StockPanel({ productId, shopId }) {
const [stock, setStock] = useState(0);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await fetch(`/api/products/${productId}/set-stock`, {
method: 'POST',
body: JSON.stringify({ stock, shop_id: shopId })
});
message.success(`库存已设置为 ${stock}`);
} catch (err) {
message.error(`设置失败: ${err.message}`);
} finally {
setSaving(false);
}
};
return (
<div>
<InputNumber
min={0}
value={stock}
onChange={setStock}
placeholder="库存数量"
/>
<Button
type="primary"
loading={saving}
onClick={handleSave}
>
</Button>
<div style={{ marginTop: 8, fontSize: 12, color: '#888' }}>
💡 0
</div>
</div>
);
}
```
---
## 10. 最佳实践
### 1. 发布流程中必须设库存
```
✅ 正确:
POST /v3/product/import → 轮询成功 → POST /v2/products/stocks
❌ 错误:
POST /v3/product/import → 轮询成功 → 结束(商品不可购买)
```
### 2. 缓存仓库 ID
```python
# 仓库 ID 不常变,启动时拉取并缓存
_warehouse_cache = {}
async def get_warehouse_id(shop_id: str):
if shop_id not in _warehouse_cache:
warehouses = await ozon_client.get_warehouses(...)
_warehouse_cache[shop_id] = warehouses[0]["warehouse_id"]
return _warehouse_cache[shop_id]
```
### 3. 库存为 0 的处理
```python
# 库存为 0 → 自动下架,但商品仍在后台
# 补货后再设置库存 → 自动上架
# 不需要删除商品,只需更新库存
```
### 4. 批量操作
```python
# 单次最多 100 个,超过需分批
async def update_large_batch(stocks: list):
results = []
for i in range(0, len(stocks), 100):
batch = stocks[i:i+100]
resp = await ozon_client.update_stocks(client_id, api_key, batch)
results.extend(resp["result"])
return results
```
---
## 相关文档
- [04-product-import.md](./04-product-import.md) —— 创建商品(发布前置)
- [05-product-info.md](./05-product-info.md) —— 查询商品信息(含库存)
- [10-prices.md](./10-prices.md) —— 价格更新
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §5 —— 发布链路(含库存设置)
+102
View File
@@ -0,0 +1,102 @@
# Ozon Seller API 文档总览
> 本目录整理 Ozon Seller API 的核心接口文档,供 ozon-seller-kit 项目集成使用。
> 官方文档:https://docs.ozon.ru/api/seller/zh/
---
## 目录
| 文档 | 内容 |
|---|---|
| [01-authentication.md](./01-authentication.md) | 鉴权方式、请求头、错误码 |
| [02-category-tree.md](./02-category-tree.md) | 类目树查询 |
| [03-category-attributes.md](./03-category-attributes.md) | 类目属性与字典值 |
| [04-product-import.md](./04-product-import.md) | 商品导入(创建/更新) |
| [05-product-info.md](./05-product-info.md) | 商品信息查询 |
| [06-product-list.md](./06-product-list.md) | 商品列表 |
| [07-import-by-sku.md](./07-import-by-sku.md) | 跟卖(按 SKU 复制) |
| [08-pictures.md](./08-pictures.md) | 图片更新 |
| [09-stocks.md](./09-stocks.md) | 库存管理 |
| [10-prices.md](./10-prices.md) | 价格更新 |
---
## 快速索引
### 核心流程
**1. 发布新商品**
```
① 获取类目树 → 选择类目 → 得 description_category_id + type_id
② 获取该类目属性 → 映射属性值
③ 组装 ImportProductsV3 请求体
④ POST /v3/product/import → 得 task_id
⑤ 轮询 POST /v1/product/import/info → 得 product_id
```
**2. 跟卖已有商品**
```
① POST /v1/product/import-by-sku(传 sku + 基本信息)
② 轮询状态
```
**3. 更新商品**
- 更新商品信息:复用 `/v3/product/import`(传 `product_id``offer_id`
- 更新图片:`POST /v1/product/pictures/import`
- 更新价格:`POST /v1/product/import/prices`
- 更新库存:`POST /v2/products/stocks`
---
## API 基础信息
| 项 | 值 |
|---|---|
| Base URL | `https://api-seller.ozon.ru` |
| 鉴权方式 | 请求头 `Client-Id` + `Api-Key` |
| 内容类型 | `application/json` |
| 超时建议 | 30s(常规)/ 90simport/轮询) |
| 限流 | 官方未明确公开限流规则,建议控制在 10 req/s |
---
## 关键约束
1. **类目选择**:只有末级类目(`disabled=false`)可建品
2. **必填字段**`name/description/category/price/尺寸重量/offer_id/images` 必填且不能为 0
3. **图片 URL**:必须是 **https 公网直链**http 会被拒绝)
4. **属性映射**`is_required=true` 的属性必须填写
5. **异步任务**`/v3/product/import` 返回 `task_id`,需轮询 `/v1/product/import/info` 获取最终状态
6. **库存必须设置**`import` 成功后商品在后台,需设置库存才能上架
---
## 错误码速查
| HTTP | 含义 | 处理 |
|---|---|---|
| 400 | 参数错误 | 检查请求体字段 |
| 403 | 权限不足 | 检查 Api-Key 权限级别 |
| 404 | 资源不存在 | 检查 product_id/category_id |
| 409 | 冲突(如 offer_id 重复) | 改 offer_id 或走更新 |
| 429 | 限流 | 指数退避重试 |
| 500 | 服务端错误 | 重试或联系支持 |
---
## V2 项目集成清单
| 接口 | 用途 | 实现状态 |
|---|---|---|
| `/v1/description-category/tree` | 类目树 | ✅ API 已建(categories.py |
| `/v1/description-category/attribute` | 类目属性 | ✅ API 已建 |
| `/v1/description-category/attribute/values` | 属性值字典 | ✅ API 已建 |
| `/v3/product/import` | 商品导入 | ✅ API 已建(publish.py |
| `/v1/product/import/info` | 导入状态 | ✅ API 已建 |
| `/v3/product/list` | 商品列表 | 🟡 待建 |
| `/v3/product/info/list` | 商品详情 | 🟡 待建 |
| `/v1/product/import-by-sku` | 跟卖 | 🟡 待建(二期) |
| `/v1/product/pictures/import` | 图片更新 | 🟡 待建(二期) |
| `/v2/products/stocks` | 库存 | 🟡 待建(二期) |
| `/v1/product/import/prices` | 价格 | 🟡 待建(二期) |
+143
View File
@@ -0,0 +1,143 @@
# Ozon Seller API 文档整理完成
已完成 Ozon Seller API 的核心接口文档整理,涵盖商品发布、管理的完整流程。
## 已完成的文档
### 核心文档(9 个)
1. **README.md** - 总览与快速索引
2. **01-authentication.md** - 鉴权方式、Base URL、错误码、限流
3. **02-category-tree.md** - 类目树查询(选择类目)
4. **03-category-attributes.md** - 类目属性与字典值(属性映射)
5. **04-product-import.md** - 商品导入/创建/更新(核心接口)
6. **05-product-info.md** - 商品详情查询(审核状态、图片、属性)
7. **06-product-list.md** - 商品列表分页(ID 收集、批量查询)
8. **09-stocks.md** - 库存管理(必须设置才能上架)
### 待补充(二期)
- **07-import-by-sku.md** - 跟卖(按 SKU 复制 PDP
- **08-pictures.md** - 图片更新
- **10-prices.md** - 价格批量更新
---
## 文档特色
### 1. 完整的代码示例
- Python(服务端)示例
- TypeScript(前端)示例
- 实际可运行的代码片段
### 2. V2 项目集成指引
- 每个文档都包含"V2 项目集成"章节
- API 层实现示例(对齐 server/ 结构)
- 前端组件示例(对齐 studio/ 结构)
### 3. 最佳实践与常见问题
- 缓存策略
- 错误处理
- 性能优化
- 分页/批量操作
### 4. 实用场景
- 发布流程(端到端)
- 属性映射工作流
- 库存同步策略
- 审核状态检查
---
## 核心流程速查
### 完整发布流程
```
① 获取类目树 → 选择类目
POST /v1/description-category/tree
② 获取类目属性 → 映射属性
POST /v1/description-category/attribute
POST /v1/description-category/attribute/values/search
③ 组装请求体 → 发布商品
POST /v3/product/import → task_id
④ 轮询状态 → 获取 product_id
POST /v1/product/import/info → status=imported
⑤ 设置库存(必须)
POST /v2/products/stocks → 商品上架
⑥ 查询详情(可选)
POST /v3/product/info/list → 审核状态/图片/库存
```
### 关键约束总结
1. **类目**:只有末级类目(`disabled=false`)可建品
2. **必填字段**name/description/category/price/尺寸重量/offer_id/images
3. **图片 URL**:必须 https 公网直链
4. **属性**`is_required=true` 的必须填写
5. **异步任务**`/import` 返回 task_id,需轮询状态
6. **库存必须设置**:不设置库存 = 商品不可购买
---
## 与 V2 项目的对应关系
| Ozon API | V2 后端 API | V2 前端页面 | 状态 |
|---|---|---|---|
| `/description-category/tree` | `/api/categories/tree` | CategoryPicker | ✅ 已建 |
| `/description-category/attribute` | `/api/categories/{id}/attributes` | AttributeMapper | ✅ 已建 |
| `/attribute/values` | `/api/categories/attribute/{id}/values` | - | ✅ 已建 |
| `/v3/product/import` | `/api/products/{id}/publish` | PublishPanel | ✅ 已建 |
| `/v1/product/import/info` | (background poll) | - | ✅ 已建 |
| `/v3/product/info/list` | `/api/products/{id}/ozon-detail` | OzonStatusBadge | 🟡 待建 |
| `/v3/product/list` | `/api/products/sync-from-ozon` | SyncButton | 🟡 待建 |
| `/v2/products/stocks` | `/api/products/{id}/set-stock` | StockPanel | 🟡 待建 |
---
## 使用建议
### 阅读顺序(新接入)
1. **01-authentication.md** - 了解鉴权与基础
2. **04-product-import.md** - 核心接口,先看这个
3. **02-category-tree.md** - 类目选择
4. **03-category-attributes.md** - 属性映射(难点)
5. **09-stocks.md** - 库存设置(必须)
6. 其他按需查阅
### 开发时查阅
- 看接口契约 → 查对应章节的"请求/响应"
- 看错误处理 → 查"常见错误"章节
- 看集成方式 → 查"V2 项目集成"章节
- 看最佳实践 → 查"使用场景"或"最佳实践"章节
---
## 下一步
### 立即可用
现有 9 个文档已覆盖 V2 项目一期的所有核心接口,可立即用于:
- 服务端 `ozon_client.py` 开发
- API 端点实现参考
- 前端组件开发参考
### 二期补充
需要时再补充:
- 跟卖(import-by-sku
- 图片单独更新
- 价格批量更新
---
## 相关文档
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) - V2 发布集成方案(与本文档配套)
- [docs/v2/api.md](../v2/api.md) - V2 后端 API 设计
- [docs/v2/database.md](../v2/database.md) - V2 数据库设计