feat(edit): 编辑页开发
This commit is contained in:
@@ -41,27 +41,28 @@
|
||||
{
|
||||
"result": [
|
||||
{
|
||||
"description_category_id": 17033876,
|
||||
"category_name": "Термокружки",
|
||||
"type_id": 97114,
|
||||
"type_name": "Термокружка",
|
||||
"disabled": false,
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"description_category_id": 17028922,
|
||||
"category_name": "Посуда",
|
||||
"description_category_id": 17000001,
|
||||
"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": 17000002,
|
||||
"category_name": "Сувениры и подарки",
|
||||
"type_id": 0,
|
||||
"type_name": "",
|
||||
"disabled": true,
|
||||
"children": [
|
||||
{
|
||||
"description_category_id": 17032010,
|
||||
"category_name": "Копилки",
|
||||
"type_id": 93770,
|
||||
"type_name": "Копилка",
|
||||
"disabled": false,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -77,16 +78,18 @@
|
||||
| category_name | string | 类目名称 |
|
||||
| type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) |
|
||||
| type_name | string | 商品类型名称 |
|
||||
| disabled | boolean | **是否禁用**。`true` = 不可建品(父类目),`false` = 可建品(末级类目) |
|
||||
| disabled | boolean | **是否可建品**。`true` = 当前节点不可用于建品;`false` = 可用于建品(通常是末级类目) |
|
||||
| children | array | 子类目(递归结构) |
|
||||
|
||||
> **类型叶子例外**:Ozon 的最后一层可能只返回 `type_name`、`type_id`、`disabled` 和空 `children`,而不重复返回 `description_category_id`、`category_name`。此时它属于最近的父类目:缓存时应继承父类目的类目 ID,展示名称使用 `type_name`,发布时使用“父类目 `description_category_id` + 叶子 `type_id`”。
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
1. **只有末级类目可建品**:`disabled=false` 的类目才能用于发布商品
|
||||
1. **只有可发布的末级类目可建品**:选择结果必须是没有 `children` 且 `disabled=false` 的节点
|
||||
2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id`
|
||||
3. **层级结构**:类目可能有多层嵌套(最多 5-6 层),需递归遍历找到末级
|
||||
3. **层级结构**:`children` 是递归结构,类目可有三级或更深层级;不能假定只有两层,也不能只展开一层
|
||||
|
||||
---
|
||||
|
||||
@@ -97,15 +100,16 @@
|
||||
```
|
||||
① 请求类目树(language=RU,给俄文用户看)
|
||||
② 递归展开树形结构
|
||||
③ 用户选择类目后,校验 disabled=false(若 true 则禁止选择或自动展开子级)
|
||||
④ 选中后保存 description_category_id + type_id
|
||||
③ 所有有子节点的目录都保持可展开;不要把目录节点映射为组件的 disabled
|
||||
④ 仅允许选择没有子节点且 disabled=false 的节点
|
||||
⑤ 选中后保存 description_category_id + type_id
|
||||
```
|
||||
|
||||
### 场景 2:服务端门店缓存
|
||||
|
||||
```
|
||||
① 门店 `Client-Id` + `Api-Key` 验证成功后拉取类目树(Atlas 使用 `ZH_HANS`)
|
||||
② 按门店存入本地类目缓存;不与其他门店混用
|
||||
① 门店 `Client-Id` + `Api-Key` 验证成功后拉取完整类目树(Atlas 使用 `ZH_HANS`)
|
||||
② 递归保存每一层及其父子关系,按门店存入本地类目缓存;不与其他门店混用
|
||||
③ 同步失败由后台任务重试,已验证的门店凭据不因此改为无效
|
||||
④ 用户选类目时直接查当前目标门店缓存,不频繁调 API
|
||||
```
|
||||
@@ -141,7 +145,7 @@ async def fetch_category_tree(
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# 递归扁平化
|
||||
# 递归扁平化;不要限制层级数。
|
||||
def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0):
|
||||
flat = []
|
||||
for node in nodes:
|
||||
@@ -184,19 +188,24 @@ async function fetchCategoryTree(language = 'RU'): Promise<CategoryNode[]> {
|
||||
return data.result;
|
||||
}
|
||||
|
||||
// 转为 antd Tree 数据结构
|
||||
// 转为 antd Cascader 数据结构。目录永远不能设为 disabled,
|
||||
// 否则用户无法继续展开到下一级。
|
||||
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
|
||||
}
|
||||
}));
|
||||
return nodes.map(node => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
return {
|
||||
value: `${node.description_category_id}-${node.type_id}`,
|
||||
label: node.category_name,
|
||||
isLeaf: !hasChildren,
|
||||
// 仅不可发布的叶子不可选择;目录节点必须可展开。
|
||||
disabled: !hasChildren && node.disabled,
|
||||
children: hasChildren ? toTreeData(node.children) : undefined,
|
||||
data: {
|
||||
description_category_id: node.description_category_id,
|
||||
type_id: node.type_id
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
@@ -204,48 +213,29 @@ function toTreeData(nodes: CategoryNode[]): any[] {
|
||||
|
||||
## 缓存策略
|
||||
|
||||
### 全局缓存(推荐)
|
||||
### 按门店缓存(Atlas 方案)
|
||||
|
||||
```python
|
||||
# 类目树与店铺无关,所有店铺共用一份
|
||||
# 启动时拉取,存内存 + 数据库
|
||||
# TTL 24h(类目变化不频繁)
|
||||
# 类目数据的读取凭证属于门店。即使结构通常相近,也必须按 store_id 隔离缓存。
|
||||
# 验证成功或缓存失效时,由 Worker 用该门店凭证拉取完整树并递归写库。
|
||||
# 前端只读取该门店的缓存,绝不携带或读取 Client-Id / Api-Key。
|
||||
async def get_store_category_tree(store_id: str):
|
||||
cached = await load_store_category_cache(store_id)
|
||||
if cached and cached.is_valid:
|
||||
return cached.tree
|
||||
|
||||
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
|
||||
await enqueue_store_catalog_sync(store_id)
|
||||
raise CatalogCachePreparingError(store_id)
|
||||
```
|
||||
|
||||
### 按需更新
|
||||
|
||||
```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()
|
||||
# 用户在门店管理中手动刷新时,API 同步请求 Ozon 并在成功后原子替换目标门店缓存。
|
||||
# 请求或落库失败会保留旧缓存并向用户返回错误,不经 Worker 队列。
|
||||
async def refresh_store_category_tree(store_id: str):
|
||||
tree = await fetch_category_tree_for_store(store_id)
|
||||
return await replace_store_category_cache_atomically(store_id, tree)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -254,7 +244,7 @@ async def refresh_category_tree():
|
||||
|
||||
### Q1: 类目树很大吗?
|
||||
|
||||
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。首次拉取需几秒,后续从缓存读取。
|
||||
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。完整树在一次响应中以递归 `children` 返回,首次拉取需几秒,后续从门店缓存读取。
|
||||
|
||||
### Q2: 多久更新一次?
|
||||
|
||||
@@ -262,67 +252,20 @@ async def refresh_category_tree():
|
||||
|
||||
### Q3: 不同语言的类目树结构一样吗?
|
||||
|
||||
**A**: 结构一致(`description_category_id` / `type_id` 相同),仅 `category_name` / `type_name` 翻译不同。建议:
|
||||
- 服务端缓存 `DEFAULT`(英文,便于代码处理)
|
||||
- 前端按用户语言拉取 `RU`(俄文,展示用)
|
||||
**A**: 通常 `description_category_id` / `type_id` 相同,仅 `category_name` / `type_name` 翻译不同。Atlas 统一由 Worker 为每个门店缓存 `ZH_HANS`,前端直接读取该门店缓存,不能为展示语言绕过缓存直接携带门店密钥请求 Ozon。
|
||||
|
||||
### Q4: `type_id=0` 是什么意思?
|
||||
|
||||
**A**: 父类目(`disabled=true`)的 `type_id` 为 0,表示该节点不是商品类型,只是分类层级。只有末级类目的 `type_id > 0`。
|
||||
**A**: 目录节点通常 `type_id=0`,但前端不能据此把所有目录设为 disabled,否则它们无法展开。是否允许提交必须同时判断:它是叶子节点、`disabled=false`,并且有可用的 `type_id`。
|
||||
|
||||
---
|
||||
|
||||
## V2 项目集成
|
||||
## Atlas 当前集成
|
||||
|
||||
### 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 }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
- **Worker**:以门店凭证调用 Ozon,递归持久化完整 `children` 树及父子关系;缓存命中时不重复调用 Ozon。
|
||||
- **API**:按当前目标门店返回缓存的扁平节点(包含 `parent_id`);不会将 `Client-Id` 或 `Api-Key` 返回给浏览器。
|
||||
- **前端**:根据 `parent_id` 重建任意深度的 Cascader;目录可展开,只有可发布的叶子能写入商品草稿。
|
||||
- **调试**:若需要查看 Ozon 原始响应,应由受权限保护的服务端调试能力获取并脱敏展示;浏览器不得直接用门店密钥请求 Ozon。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user