Files
ozon-seller-kit/docs/ozon-seller-api/02-category-tree.md
T

8.5 KiB
Raw Blame History

类目树查询 API

官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetTree


接口信息

方法 POST
路径 /v1/description-category/tree
鉴权 需要 Client-Id + Api-Key
用途 获取 Ozon 商品类目树(选择类目后才能发布商品)

请求

请求体

{
  "language": "RU"
}

参数说明

字段 类型 必填 说明
language string 可选 语言代码,可选值:DEFAULT(英文)、RU(俄文)、EN(英文)、ZH_HANS(简体中文)。默认 DEFAULT

响应

成功响应(200

{
  "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(服务端缓存)

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(前端选择器)

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
    }
  }));
}

缓存策略

全局缓存(推荐)

# 类目树与店铺无关,所有店铺共用一份
# 启动时拉取,存内存 + 数据库
# 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

按需更新

# 用户反馈「找不到某类目」时手动刷新
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 层(已实现)

# 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}

前端(待实现)

// 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 }
        }
      }}
    />
  );
}

相关文档