做外卖项目做到分类管理这个节点算是真正开始进入业务层面了。前面无非是搭框架、写登录、做JWT拦截器说白了是通用技术功底但分类管理不一样它是第一个要你认真思考这个系统到底怎么运转的功能——前台要展示菜品后台要维护数据分类就是连接这两端的骨架。我当初做苍穹外卖到Day 2.5的时候一开始只觉得这不就是个增删改查吗真正写完、调试完、再回头看的时候才意识到这个模块把整套项目的大半知识点都穿起来了分页、动态SQL、事务、日志、参数校验、前后端交互、甚至Redis缓存都能在上面做文章。这篇文章我就按自己踩过的路线把分类管理完整拆一遍包括代码层面的实现逻辑、容易忽略的坑、以及怎么让你的代码比教程更好用。1. 分类管理的业务边界为什么它值得单独占半天的内容1.1 分类在苍穹外卖里到底管什么如果说菜品和套餐是货那分类就是货架。用户打开外卖App最先看到的不可能是某个具体菜品而是分类——热销榜下饭菜主食汤每个分类底下才有具体的商品。后端管理端则由运营人员维护这些分类决定哪些分类上架、哪些停用、排序顺序等等。苍穹外卖把分类设计为两种类型通过type字段区分type值业务含义举例1菜品分类热销、主食、饮品2套餐分类单人套餐、双人套餐、家庭套餐这个设计是符合真实业务场景的。菜品分类和套餐分类虽然都叫分类但在前台展示上属于两个完全独立的体系——菜品走点餐流套餐走套餐流。如果混在一张表里不区分类型前台查询时就得多加一堆逻辑。用type字段隔离反而让查询最简单直接一条where type xxx就能切分清楚。1.2 分类表字段设计的底层逻辑先看分类表结构这是后端所有操作的基础。苍穹外卖的分类表sky_take_out 中常见的category表核心字段如下字段名类型说明idbigint主键自增typeint分类类型1为菜品分类2为套餐分类namevarchar(32)分类名称唯一在type维度内sortint排序字段数值越小越靠前statusint状态0禁用1启用默认1create_timedatetime创建时间update_timedatetime更新时间create_userbigint创建人IDupdate_userbigint修改人ID我最早看这个表的时候觉得字段平平无奇直到做接口才明白每个字段的用途sort字段是排序用的前端页面上上移下移或者拖拽最终都会落到这个值上。数值越小排越前面这个约定要和前端定死。status用于软性上下架。注意分类的禁用状态和删除是两个概念——禁用只是前台不可见数据还在删除是物理移除。create_user和update_user在设计时容易被忽略但它们贯穿了整个项目的操作日志体系。后面做数据审计、排查谁改了数据时这两个字段会救命。1.3 为什么说分类管理是承上启下的模块技术角度讲分类管理是所有具备树形或扇形展示结构的业务模块的地基。你在它身上第一次接触到的几个通用能力通用的分页查询流程前端传页码、每页条数、查询条件后端用PageHelper或者手动拼LIMIT返回分页结果。这个模式在之后所有列表页里都会用到。通用的状态流转模式启用/禁用本质上是对一条记录的单个字段做更新。这个模式在菜品管理、套餐管理里一模一样。通用的删除保护模式分类下如果挂了菜品/套餐就不允许删除。这种先检查关联数据再删的套路在之后所有主从表结构里都是核心逻辑。所以半天时间学分类管理并不亏。它把整个管理端的标准操作流程几乎都预习了一遍。2. 管理端分类分页查询最容易被小看的接口2.1 接口约定与前端交互形式管理端分类管理的界面一般左侧是分类列表右侧是当前分类下的数据。点开分类管理菜单默认展示菜品分类可以切换到套餐分类。页面底部是一套分页组件可以跳页、改每页条数。对应到后端接口苍穹外卖中的分类分页查询接口标准定义大概是GET /admin/category/page 参数 name: 可选按名称模糊查询 type: 可选1菜品分类 2套餐分类 page: 页码默认1 pageSize: 每页条数默认10这里有个容易忽略的点前端切到套餐分类标签页时并不会重新生成一个接口而是复用同一个分页接口、多传一个type2。所以你在后端没有必要单独写菜品分类分页接口和套餐分类分页接口做好type参数的可选处理即可。2.2 Controller、Service、Mapper三层代码实现先看Controller层。苍穹外卖用的是Spring Boot标准结构是Controller收到请求后交给ServiceService再调用Mapper。代码大体这样RestController RequestMapping(/admin/category) Slf4j public class CategoryController { Autowired private CategoryService categoryService; GetMapping(/page) public ResultPageResult page(CategoryPageQueryDTO categoryPageQueryDTO) { PageResult pageResult categoryService.pageQuery(categoryPageQueryDTO); return Result.success(pageResult); } }CategoryPageQueryDTO里就是那四个可选参数name、type、page、pageSize。这里我后来优化时给它加了默认值Data public class CategoryPageQueryDTO { private String name; private Integer type; private Integer page 1; private Integer pageSize 10; }给page和pageSize加默认值很重要。前端如果出现漏传参数的情况后端不至于报空指针而是按默认值查询。这种防御性设计在正式项目里非常常见教程里往往不会特别强调但实际联调时很省事。Service层实现使用PageHelper分页Override public PageResult pageQuery(CategoryPageQueryDTO categoryPageQueryDTO) { // 1. 设置分页参数 PageHelper.startPage(categoryPageQueryDTO.getPage(), categoryPageQueryDTO.getPageSize()); // 2. 执行查询 ListCategory categoryList categoryMapper.pageQuery(categoryPageQueryDTO); // 3. PageHelper会把总记录数放到PageInfo里 PageCategory page (PageCategory) categoryList; long total page.getTotal(); // 4. 封装返回结果 return new PageResult(total, categoryList); }PageHelper的用法是个经典套路在Mapper查询执行前调用PageHelper.startPage(page, pageSize)它会在MyBatis执行查询时自动拼接LIMIT 语句并且把总记录数塞到返回的List实际类型——Page对象中。这一步极度依赖顺序startPage必须写在Mapper方法调用之前写在后面就完全不生效了。2.3 Mapper XML中的动态SQL写法与排序细节Mapper接口方法很简单ListCategory pageQuery(CategoryPageQueryDTO categoryPageQueryDTO);对应的XML是真正的关键所在select idpageQuery resultTypecom.sky.entity.Category select * from category where if testname ! null and name ! and name like concat(%, #{name}, %) /if if testtype ! null and type #{type} /if /where order by sort asc, id desc /select几个细节逐一说清楚第一where标签自动处理掉多余的AND。当第一个条件不成立时第二个条件的and会被MyBatis的where标签智能移除。如果我手动写成where 11再加条件也能跑通但看起来不够干净where是更标准的做法。第二name like concat(%, #{name}, %)不能写成%${name}%。前者用#{name}走预编译没有SQL注入风险后者是字符串拼接攻击者可以在name里传特殊字符直接拼SQL。新手在这里图省事用了${}等上了安全扫描就会被标记成高危漏洞。这一处务必守住。第三排序规则。我写过order by sort asc后来发现当多个分类sort值相同时查询结果顺序不稳定刷新一次页面顺序变一次。这是因为数据库不保证在没有唯一排序键时的返回顺序。改成order by sort asc, id desc后即使sort相同也会按id倒序兜底结果稳定用户在前端拖拽排序时也不会看到列表跳动。2.4 分页结果的统一封装苍穹外卖的分页结果统一用PageResult封装结构是{ total, records }。这算是个约定所有分页接口返回结构都一样前端就不用为每个接口单独写解析逻辑。Data AllArgsConstructor NoArgsConstructor public class PageResult { private long total; private List? records; }前端拿到total就知道一共多少条再根据pageSize算出总页数就能渲染分页组件了。3. 新增分类与状态管理从参数校验到启停用的完整链路3.1 新增分类的幂等性与唯一性校验新增分类接口通常是POST /admin/category 请求体 { type: 1, name: 主食, sort: 5 }Service实现里最重要的不是insert本身而是insert前的那道校验——分类名称不能重复。Override public void save(CategoryDTO categoryDTO) { // 1. 参数校验名称不能为空 if (categoryDTO.getName() null || categoryDTO.getName().trim().isEmpty()) { throw new BaseException(分类名称不能为空); } // 2. 分类名称在当前类型下唯一 int count categoryMapper.countByNameAndType(categoryDTO.getName(), categoryDTO.getType()); if (count 0) { throw new BaseException(分类名称已存在); } // 3. 数据组装 Category category new Category(); BeanUtils.copyProperties(categoryDTO, category); category.setStatus(1); // 新增默认启用 category.setCreateTime(LocalDateTime.now()); category.setUpdateTime(LocalDateTime.now()); category.setCreateUser(BaseContext.getCurrentId()); category.setUpdateUser(BaseContext.getCurrentId()); categoryMapper.insert(category); }为什么我要特意加当前类型下唯一而不是全表唯一因为业务上菜品分类和套餐分类是两个展示维度允许出现同名。比如菜品分类可以叫双人餐、套餐分类也可以叫双人餐互不干扰。如果我把唯一性做成全局的后面运营说我要加一个和另一个类型同名的分类就会被拦住完全不合理。这种细节就是业务的魂。BaseContext.getCurrentId()是苍穹外卖里的一个ThreadLocal工具类用来获取当前登录用户的ID。这样create_user和update_user就不用手动传JWT拦截器在认证通过时已经把用户ID放进ThreadLocal了。这背后的原理是HTTP请求线程隔离ThreadLocal只能取到当前线程的数据所以每个请求都只能拿到自己的用户信息不会串号。3.2 启用/禁用分类为什么一个字段要单独做接口分类管理的界面上每个分类后面都有启用/禁用的开关。这个开关对应后端接口POST /admin/category/status/{status} 请求参数id苍穹外卖的做法是把status放在路径里PostMapping(/status/{status}) public Result startOrStop(PathVariable Integer status, Long id) { categoryService.startOrStop(status, id); return Result.success(); }Service实现非常轻量public void startOrStop(Integer status, Long id) { Category category Category.builder() .id(id) .status(status) .updateTime(LocalDateTime.now()) .updateUser(BaseContext.getCurrentId()) .build(); categoryMapper.update(category); }有人会问为什么不做一个通用的字段更新接口前端传哪个字段就更新哪个字段我劝你不要这么做。通用更新接口初看灵活实际上是个坑——它绕过了所有业务校验把数据完整性责任全部丢给了前端后端完全失控。一旦前端联合调试时传错字段比如把status传成了字符串轻则脏数据重则直接400。分类管理的状态更新就传status一个字段接口语义清晰、测试容易覆盖这才是业务系统该有的样子。实际开发中Cloud项目里很多人会觉得起个接口太麻烦了直接把status拼进编辑接口更新时一起提交不就行了表面看是的但编辑接口通常只有运营人员能操作启用/禁用也可能被其他角色操作比如门店店长未来权限拆分时单独接口能精确控制。而且禁用操作往往需要额外的联动逻辑——比如分类禁用后该分类下的菜品在前台也不该展示。这个联动将来放在独立的startOrStop接口里做再合适不过。3.3 新增分类时的并发问题这里提一个进阶场景面试的时候经常会问如果两个运营同时新增同名分类怎么办前面的countByNameAndType在单请求下没问题但并发时两个请求都查到count0然后都执行insert就产生了重复数据。要彻底防住有两个方案数据库唯一索引在category表上建(name, type)联合唯一索引数据库层面直接拒绝重复插入。这是最可靠的方案。应用层锁用分布式锁或synchronized锁住name type这个key。单机用synchronized够用分布式就要引入Redisson之类的组件。排名第一的方案永远是数据库层兜底应用层校验属于提升用户体验——把丑陋的数据库异常转成分类名称已存在的业务提示。我建议两个都做数据库加唯一索引应用层保留友好校验。这也是我在实际项目中的最终做法。4. 编辑与删除最容易踩坑的业务规则在哪儿4.1 编辑分类时的字段选择编辑分类接口PUT /admin/category 请求体 { id: 1, name: 新名称, sort: 3 }实现上同样是先校验名字是否存在排除当前id自己然后更新字段。这里有个关键点type字段在编辑时到底能不能改按苍穹外卖的业务设计分类创建后类型是锁死的。一个菜品分类不应该被运营改成套餐分类因为该分类下可能已经挂了几十个菜品一旦改类型这批菜品的分类归属就全乱了。所以编辑接口中我通常不接收type字段或者接收了也在Service里直接忽略。学会拒绝不合理的字段更新是区分初学者的重要标志。不是前端传什么你都更新你要判断哪些字段是业务上允许被改的。4.2 删除分类关联数据检查的完整实现删除分类接口DELETE /admin/category?id1最核心的就是分类下有菜品/套餐时不允许删除。这个逻辑对应一条铁律外键约束不够时业务层必须显式校验。先看MQ或者Service层的完整流程Override public void deleteById(Long id) { // 1. 查询分类是否关联了菜品 Integer dishCount dishMapper.countByCategoryId(id); if (dishCount ! null dishCount 0) { throw new DeletionNotAllowedException(MessageConstant.CATEGORY_BE_RELATED_BY_DISH); } // 2. 查询分类是否关联了套餐 Integer setmealCount setmealMapper.countByCategoryId(id); if (setmealCount ! null setmealCount 0) { throw new DeletionNotAllowedException(MessageConstant.CATEGORY_BE_RELATED_BY_SETMEAL); } // 3. 都不存在关联物理删除 categoryMapper.deleteById(id); }关键的检查逻辑都在Mapper里比如菜品表里查一下select idcountByCategoryId resultTypejava.lang.Integer select count(*) from dish where category_id #{categoryId} /selectdao层查询开销很小因为category_id在菜品表上有索引。如果没索引这个count在数据量大时会拖慢删除接口这是建表时就应该规划好的。4.3 删除的另一种选择逻辑删除与物理删除苍穹外卖的分类删除是物理删除——直接从表里删掉。但真实项目中我接手的系统往往不允许物理删除原因是大规模的物理删除会带来几个麻烦审计困难有一天用户投诉某分类不见了物理删除后你连痕迹都查不到。关联数据悬挂即使通过检查阻止了关联菜品下的分类删除历史报表、订单快照里仍可能引用这个分类ID。物理删除后这些历史数据的分类名称变成了未知。所以很多企业级项目采用逻辑删除表上增加deleted字段删除时执行update category set deleted 1查询时统一过滤deleted 0。但是不要因为我说逻辑删除好用就把手头所有表改成逻辑删除。苍穹外卖作为教学项目重点在于学原理物理删除逻辑简单直接而且对于分类这种低频变化的配置型数据物理删除的风险是可控的。真做商业项目时再结合需求权衡。4.4 遇到删除失败的前端提示细节关联检查的异常信息会被全局异常处理器捕获转成JSON返回前端。前端在这个基础上弹窗显示当前分类下存在菜品无法删除。但如果你以为只要弹个提示就完事那就晚了。更好的前端交互是在删除按钮点击前就根据分类状态判断是否需要禁用删除按钮。比如分类下是否有关联数据列表接口中可以额外返回一个isDeleted布尔值。用户根本点不到删除按钮比点击后才弹窗报错高一个体验层级。我后来在真实项目中做分类管理时就在分页列表VO里增加了relatedCount字段分类下有几道菜一目了然。运营人员还没点删除就已经知道删不掉的原因投诉率直线下降。5. 客户端分类列表与缓存设计让读多写少的接口更扛压5.1 客户端展示分类的接口逻辑管理端的分类管理做得再花哨最终目的是让前台能看到分类列表。客户端接口很简单只暴露启用状态的分类并且按排序字段排列GET /user/category/list 参数type1菜品 2套餐Service实现public ListCategory list(Integer type) { return categoryMapper.listByTypeAndStatus(type, 1); }Mapper查询select idlistByTypeAndStatus resultTypecom.sky.entity.Category select * from category where type #{type} and status 1 order by sort asc, id desc /select这个接口不涉及分页——前台分类列表通常需要一次性加载全部启用分类总数不会太多几十条顶天。分页反而会打断页面的整体呈现。5.2 为什么这个接口值得加Redis缓存客户端分类列表是典型的高频读、低频写接口。用户每次进入首页App都会请求一次分类列表高峰期1000个用户打开首页就是1000次查询而这个列表可能一整天都不会变一次。给这样的接口加缓存是性价比极高的优化。当运营修改分类时删除缓存下次请求再回源数据库加载新分类列表Redis里的分类数据格式通常是JSON字符串key设计set:dish:category (菜品分类列表) set:setmeal:category (套餐分类列表)不过需要说明苍穹外卖基础篇的Day2.5核心是管理端功能缓存这部分属于进阶补充。但既然聊到了客户端列表提前把缓存方案埋进去后面做缓存章节时你会有一种原来如此的贯通感。实现伪代码如下public ListCategory list(Integer type) { String key set:category: type; String jsonStr stringRedisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(jsonStr)) { return JSON.parseArray(jsonStr, Category.class); } ListCategory list categoryMapper.listByTypeAndStatus(type, 1); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(list), 30, TimeUnit.MINUTES); return list; }5.3 缓存失效策略什么时候删什么时候不用管管理端任何增删改操作影响分类列表后都应该主动删除对应类型的缓存而不是等过期。落实到代码里新增分类删对应type的缓存key修改分类名称/排序删对应type的缓存key启用/禁用分类删对应type的缓存key删除分类删对应type的缓存key说白了所有可能改变客户端看到的分类列表的操作都要让缓存失效。最容易漏的是启用/禁用操作很多人只删了管理端的接口数据缓存忘了客户端列表还顶着一张旧脸。另外一个实际的坑如果同时有多个微服务实例只用本地缓存比如Caffeine会出现各实例数据不一致。Redis缓存天然规避了这个问题因为所有实例访问的是同一个Redis。从教学项目迁移到微服务架构时这个选择会让你省掉很多烦心事。6. 我从分类管理里学到的三个超纲经验6.1 参数校验永远不要只依赖前端分类新增时前端确实会做必填校验但接口是公开可调用的Postman直接打接口就能绕过前端。所以后端一定要自己校验。我后来给分类管理的DTO加上了NotNull、Size这类JSR-303注解少数手工校验用Spring的Validated触发比自己在Service里写一堆if判断清爽得多。但如果你只是跟着课程做Service里手写校验也能过关关键是必须有而不是必须用哪个框架。6.2 MyBatis动态SQL里的if测试要写对动态SQL的if testname ! null and name ! 看起来很普通但很多人栽在使用条件上。比如有人写成if testname ! 当name为null时MyBatis判断null不等于空字符串结果为true照样拼了and name like concat(%,#{name},%)。如果Java端传进来的是nullMyBatis一般能识别并生成like %%全表扫描还能查到可一旦有的环境配置不同就会出现异常或者查询出意外结果。规范写法就是name ! null and name ! 两个条件都写上不要偷懒。6.3 日志和操作者信息要当成一等公民我做分类接口时在Service里加了Log注解或者手动打印操作日志新增分类type1, name主食, operator3, time2024-11-24 10:22:33看似不起眼但有一次线上排查某个分类被谁改成了禁用状态全靠这条日志定位到了操作人。create_user、update_user这两个字段在表里存着不表示日志口径统一了一定要既存库又打日志两条线并存排查问题时才最顺手。7. 一次完整的联调自测清单最后分享一个我在做完分类管理后用来做自测的清单照着过一遍基本能确认代码没有大数据量下的硬伤场景预期结果关键验证点新增菜品分类成功sort生效status默认为1数据入库字段完整create_user有值新增同名分类提示分类名称已存在被封住的是同type下重复新增类型不同但名称相同允许成功type唯一性范围正确分页搜索按名称模糊搜索返回匹配项total正确分页后用name过滤total是过滤后总数分页搜索不传page/pageSize按默认1和10返回没有空指针结果一致禁用已启用分类修改status成功update_time刷新仅改动status字段其他字段不动删除无关联分类成功删除关联表计数为0删除有菜品关联的分类报分类关联菜品异常删除前count判断生效删除有套餐关联的分类报分类关联套餐异常套餐表计数判断生效客户端列表只取启用分类只出现status1的记录按sort升序排序稳定多次请求顺序一致启用/禁用后客户端缓存清理客户端下一次请求拿到新列表缓存key按分类类型精确失效每次改完代码我都跑一遍这张表十五分钟不到但省下的联调扯皮时间远超投入。分类管理这个模块做完你对苍穹外卖整个项目的操作类接口手感就建立起来了。后面再做菜品管理、套餐管理你会发现骨架几乎一样区别只在业务校验的复杂度上。所谓基本功就是把这种骨架做到肌肉记忆——不用看代码就能写出Service层该有的三件套校验、业务逻辑、数据入库且每个环节都不漏。Day 2.5听起来像是课程进度的一半但它其实是整个管理端开发真正的起点。把它吃透后面的路会顺很多。