☰
烘焙坊后台分类管理实战:FastAPI+SQLAlchemy从建表到联调全解析
2026/10/14 22:53:52 网站建设 项目流程

这个烘焙坊系列做到第3篇,前两篇把项目骨架、管理员登录和账号体系跑通了,今天轮到分类管理页面。你可能觉得分类这功能太基础,无非是加个表、写几个增删改查接口,实际上它牵扯的面不小——后台侧边导航、小程序端首页商品分组、库存台账、甚至月底统计品类销量,全都在围着这张表转。我在这个项目里把分类管理做完后,最大的感受是:基础资料模块的坑,往往不是复杂,而是边界没提前说清。这篇就把我在分类管理这块从建表到接口、再到联调踩坑的完整过程写出来,给正在做类似管理系统的朋友一条能直接上手的路径。

先说下项目背景。这个项目是给某家烘焙坊做的管理后台加小程序商城,技术栈用的是 FastAPI + SQLAlchemy + MySQL,管理端页面是 Vue3。分类管理页面的核心任务很明确:运营在后台维护商品分类树,新增、编辑、排序、上下架,小程序端按分类导航展示商品。整篇我会围绕后端接口设计与批量数据组装展开,前端交互只在跟接口约定相关时提一下。

1. 先把“分类”放进烘焙坊的经营场景里定需求

1.1 分类不只是给商品分组

很多人一上来就建表写接口,觉得分类就是两级别或者三级别的父子关系,字段无非是id、name、parent_id。实际动手前,我把这分类的使用方列了一遍,发现至少有三类角色在等这份数据:

  • 后台运营人员:要按分类批量管理商品,比如把某个分类下的商品全部下架,或者在某个分类下快速筛选商品、设置推荐位。
  • 小程序端顾客:靠分类导航找货。烘焙坊的场景里,“早餐面包”“生日蛋糕”“伴手礼盒”“下午茶点心”这种分类名直接决定顾客愿不愿意点进去。
  • 报表统计:月底看各分类的销量占比,给采购和备货做参考。如果分类口径混乱,比如有的商品挂在“面包”下,有的挂在“西式糕点”下,统计出来的报表说服力就差很多。

所以分类接口返回的字段,必须同时满足这三类用途。我当时一开始只想做一个两级分类,结果运营提出还要“节日礼盒”这种独立维度,后来折中成一级分类下挂二级,用一张自关联表解决,不搞无限层级。这个决策我直到现在都觉得是对的:无限层级虽然看起来高级,但管理端渲染、权限联动、商品归属判断都会复杂几倍,对小体量项目完全不划算。

1.2 层级、状态与排序:口径越早定越好

我最终定了这么几个业务口径:

  • 最多两级分类,一级为父分类,二级为子分类,商品挂在二级分类下面。
  • 分类有状态:上架/下架,下架的分类在C端导航里不展示,但后台仍然保留可编辑。
  • 分类有排序值:前端拖拽排序后把整个列表的顺序提交给后端,后端整体保存。
  • 分类可以逻辑删除:但不允许删除有商品的分类;有子分类时也不能删除,只能先处理子分类或全部下架。

这些口径在后端建表和设计接口时,都会一条条落到数据库约束和逻辑判断上。不要等到前端页面画好了再说“我们要支持无限层级”,那时候改表是小,上下层接口、缓存和报表全要跟着动,代价就大了。

2. 建表设计:一张category表如何做到后面不返工

2.1 字段逐个过一遍

我最终的表结构是这样:

CREATE TABLE `category` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '分类ID', `parent_id` INT NOT NULL DEFAULT 0 COMMENT '父分类ID,0表示顶级', `name` VARCHAR(50) NOT NULL COMMENT '分类名称', `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0下架 1上架', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记', PRIMARY KEY (`id`), UNIQUE KEY `uk_name_parent` (`parent_id`, `name`), KEY `idx_parent_sort` (`parent_id`, `sort_order`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表';

逐个说一下字段背后的考虑。

parent_id 用 0 而不是 NULL 表示顶级分类。这是我在多个项目里对比过后的最优解。用 NULL 的话,查顶级分类得写WHERE parent_id IS NULL,前端 tree 组件还得兼容 null 根节点,联调时会多出一堆边界判断。用 0 表示顶级,查询条件简单直观,前端也习惯把 parentId=0 当作根节点。

唯一索引建在(parent_id, name)而不是name单独唯一。因为分类重名的约束是“同一层级下不允许重名”,不同层级下叫“招牌系列”可能有两个,一个挂在“面包”下,另一个挂在“伴手礼”下,这不算冲突。如果把 name 设为全局唯一,运营会很痛苦。

is_deleted 软删除字段。分类这种基础数据一旦被物理删除,历史订单里存的商品分类 ID 就断了,报表统计会变成一团乱麻。我统一加了 is_deleted 字段,查询列表时默认过滤掉已删除的,保底还能从数据库里捞回来。

sort_order 用 INT 而不是 DECIMAL。以前我犯过用 DECIMAL(10,2) 的错,想着排序时能插到 1.5、1.25 这种位置,给后续微调留空间。结果排序次数一多,小数位就乱套,得定期整理排序值。后来干脆全程按整数走,拖拽排序每次增量移动按 1 个单位处理,简单直接。

2.2 约束和索引:宁缺毋滥但关键不能少

索引上我没有过度设计,两个索引就够用:

  • uk_name_parent:保证同层不重名,这是业务硬约束。
  • idx_parent_sort:列表查询基本都是WHERE parent_id = ? ORDER BY sort_order ASC,这个联合索引能覆盖住。

注意这里没给 status 单独建索引,因为分类数据量撑死几百条,MySQL 全表扫一下也没任何压力,属于典型的“低基数索引没用”场景。有些同事上来就KEY idx_status (status),我觉得纯属凑数,加了反而浪费空间。

3. 接口层设计:让管理页面拿到逻辑清晰的分类结构

3.1 接口清单与核心路由

我按管理页面的实际使用场景,把接口拆成了六个:

方法路径功能
GET/api/admin/categories/tree查询全部分类树(含已下架,管理端用)
GET/api/admin/categories/options查询全部分类扁平列表(下拉框用)
POST/api/admin/categories新增分类
PUT/api/admin/categories/{id}修改分类名称、排序、状态
DELETE/api/admin/categories/{id}删除分类(会做依赖检查)
PUT/api/admin/categories/sort批量保存排序

这里有一个重点:tree 接口和 options 接口必须分开。管理页左侧树菜单用 tree 结构,新增商品时选择所属分类,需要的是扁平列表,因为下拉框组件要的是“面包 -> 全麦吐司”这种平铺的可选项。我一开始试图让一个接口同时服务这两个场景,结果前端要做二次转换,后端又得兼顾两种数据结构,两头不讨好。后来拆开,每个接口只服务一个场景,反而清爽。

3.2 统一返回格式与错误码

接口返回格式我统一这样组织:

{ "code": 0, "message": "ok", "data": { } }

业务错误码单独定义,不用 HTTP 状态码表达具体业务错误:

错误码含义
1001分类不存在
1002同级下分类名称重复
1003存在子分类,不能删除
1004分类下存在商品,不能删除
1005父分类不能指向自己

为什么不用 HTTP 状态码?因为前端要统一处理业务提示。比如删除失败时,后端返回{"code": 1003, "message": "存在子分类,不能删除"},前端直接弹 message 就行。如果走 HTTP 409,前端还得从响应体里推测业务语义,后面加错误码时前端会改到崩溃。

3.3 列表接口用树结构还是扁平行

tree 接口的核心逻辑是组装树。这里最容易写错的地方是递归查数据库——每查一层发起一次 SQL,层级一多直接卡死。正确做法是一次性把所有未删除分类全部捞出来,然后在内存里组装 children。

我用 SQLAlchemy 的伪代码如下:

def build_category_tree(): categories = db.query(Category).filter( Category.is_deleted == 0 ).order_by(Category.parent_id, Category.sort_order).all() node_map = {} roots = [] for cat in categories: node = { "id": cat.id, "parent_id": cat.parent_id, "name": cat.name, "status": cat.status, "sort_order": cat.sort_order, "children": [] } node_map[cat.id] = node if cat.parent_id == 0: roots.append(node) for cat in categories: if cat.parent_id != 0 and cat.parent_id in node_map: node_map[cat.parent_id]["children"].append(node_map[cat.id]) return roots

这种内存组装的思路,不管分类有多少条,都只有一条 SQL,耗时稳定在毫秒级。我见过有人用递归查询做同样的功能,分类只有几十条时响应也能到几百毫秒,纯属没必要。

4. 删除分类的隐藏逻辑:事务、依赖检查与停用策略

4.1 直接删一行?不行

分类删除是这套接口里最容易踩坑的地方。因为分类一旦被商品引用,或者下面还有子分类,直接 delete 会引发一连串问题:商品找不到归属分类、历史订单统计断裂、管理页面出现残留缓存。

我的删除逻辑是这样:

def delete_category(category_id: int): category = db.query(Category).filter( Category.id == category_id, Category.is_deleted == 0 ).first() if not category: raise BizError(1001, "分类不存在") child_count = db.query(Category).filter( Category.parent_id == category_id, Category.is_deleted == 0 ).count() if child_count > 0: raise BizError(1003, "存在子分类,不能删除") product_count = db.query(Product).filter( Product.category_id == category_id, Product.is_deleted == 0 ).count() if product_count > 0: raise BizError(1004, "分类下存在商品,不能删除") category.is_deleted = 1 category.status = 0 db.commit()

为什么最终选择“停用代替删除”?因为前两步已经把有子分类、有商品的分类挡掉了,剩下来能执行删除的,基本是空分类或者运营建错的分类。这种情况下,逻辑删除足够,还能保留操作痕迹。真想物理删除,交给 DBA 定期清理就行,应用层不做物理删除。

4.2 并发场景下的事务边界

这里有个细节很多人会忽略:删除前查了分类是否存在、子分类和商品数量是否为零,然后执行逻辑删除。如果两个管理员同时操作,比如一个人删分类,另一个人同时往这个分类下面加商品,就可能出现“删掉的分类又挂上了商品”这种诡异情况。

处理方式有两种:

  • 悲观锁:把查询分类那一步改成SELECT ... FOR UPDATE,锁住分类行,后续的 count 判断都在锁内执行。这种方式最稳妥,但会让商品创建和分类删除产生耦合,量小可以接受。
  • 乐观锁:删除时执行UPDATE category SET is_deleted = 1 WHERE id = ? AND is_deleted = 0,通过受影响行数判断是否已被别人删除。如果行数为 0,说明分类已经处于删除状态,直接返回“分类不存在”。

实际项目里我用的乐观锁思路,简单不阻塞,能满足后台运营场景。商品创建接口里再查一次分类是否存在且上架,虽然中间有个极小的时间窗口,但对这个体量的系统完全够用。

5. 联调阶段最容易出问题的几个约定

5.1 排序方式的约定

排序是分类管理页面联调时最容易被忽略的环节。前端拖拽排序后,我约定整体提交,而不是逐个调用排序接口:

PUT /api/admin/categories/sort [ {"id": 3, "sort_order": 1}, {"id": 1, "sort_order": 2}, {"id": 5, "sort_order": 3} ]

后端在一个事务里循环更新,同时要检查同一个 parent_id 下有没有重复的 sort_order。如果前端传了树里的顺序,后端直接按数组顺序覆盖,不校验父子层级;如果前端没传 parent_id,后端会根据每个 id 查一次父级,再按 parent_id 分组校验。这里踩过的一个坑是:前端把两个不同父分类的子项混在一个数组里提交,导致排序错乱。后来我的约定是前端按父分类分组提交,一次请求只更新同一个父分类下的子项排序。

5.2 状态切换与重复提交

上架/下架我用PUT /api/admin/categories/{id},请求体里带status字段。这里强调一个幂等性问题:连续两次提交同样的 status,后端要返回成功,而不是报错“已经是下架状态”。因为前端按钮点击后,用户手抖连点两次很常见,后端如果对重复提交报错,弹窗提示会非常烦人。

category.status = new_status db.commit()

不需要先查旧状态再判断,直接赋值提交,天然幂等。

5.3 时间、空值和默认值

联调时琐碎的坑往往最耗时,我自己遇到过的:

  • 时间格式统一返回字符串:2025-04-01 10:30:00,前端直接展示,不做时区转换。别返回 ISO 格式带T和Z的字符串,前端一顿转换,两个时区差聊起来能浪费半天。
  • parent_id 不存在时返回业务错误:新增分类时,如果传入的 parent_id 不存在,不能静默当作顶级分类处理,否则前端会看到分类莫名挂到了根节点。
  • is_deleted 字段不要暴露给前端:前端根本不关心这个字段,但如果不小心透出,前端渲染时得自己过滤已删除分类,逻辑就重复了。

6. 上线前自检与后续扩展思路

6.1 接口验收清单

分类接口写完,我习惯跑一遍手工验收清单,防止上线后被运营找上门。这个清单也可以留给测试同学:

用例预期
新增顶级分类成功,树列表出现
新增同层同名分类返回重名错误
新增子分类挂在已删除分类下父分类校验失败
修改分类 parent_id 为自己返回非法父级
删除有子分类的分类返回存在子分类错误
删除有商品的分类返回存在商品错误
连续两次下架同一分类第一次成功,第二次正常返回
批量排序提交时排序值重叠批量排序接口拒绝

这组用例看起来简单,但每一项都对应真实业务里可能出现的问题。尤其是“删除有商品的分类”和“存在子分类不能删除”这两条,能挡住绝大多数低级事故。

6.2 日志、审计与查询优化的小事

分类管理这种低频操作,我额外加了审计表,统一记录操作人、操作时间、操作前快照、操作后快照。运营在后台改分类结构时出了问题可以追溯,不用翻数据库 binlog。如果不想建表,至少在日志里结构化打印请求体与操作人,出问题时能定位到是谁干的。

还有一个很容易忽略的点:分类表本身数据量小,但商品列表页、订单详情页会频繁引用分类名称,如果不注意,很容易写出 N+1 查询。比如商品列表返回 100 条数据,每条都执行一次SELECT name FROM category WHERE id = ?,就是 100 次 SQL。正确做法是把商品的 category_id 收集起来,一次性批量查分类,再组装成 id->name 的映射。这个优化在分类管理接口本身看不到,但在被引用的模块里感知特别明显,我实际做商品列表接口时,用这个方案把平均响应时间从几百毫秒压到了几十毫秒。

分类这种基础数据页,接口本身很简单,难的是把边界说清楚。你把这几个边界卡住,后面商品、库存、订单模块引过来的时候,基本不用返工,这也是我希望这篇梳理能帮到你的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询