feat: 开发采集、采集箱和商品编辑功能
This commit is contained in:
@@ -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 —— 属性映射策略
|
||||
Reference in New Issue
Block a user