# 类目树查询 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 { 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 ( { 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 —— 类目字典缓存策略