334 lines
8.5 KiB
Markdown
334 lines
8.5 KiB
Markdown
# 类目树查询 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 —— 类目字典缓存策略
|