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
@@ -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 —— 属性映射策略