☰
外卖项目分类管理实战:分页查询、动态SQL与缓存设计
2026/10/9 6:59:46 网站建设 项目流程

做外卖项目做到分类管理这个节点,算是真正开始进入"业务"层面了。前面无非是搭框架、写登录、做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启用,默认1
create_timedatetime创建时间
update_timedatetime更新时间
create_userbigint创建人ID
update_userbigint修改人ID

我最早看这个表的时候觉得字段平平无奇,直到做接口才明白每个字段的用途:

  • sort字段是排序用的,前端页面上"上移""下移"或者拖拽最终都会落到这个值上。数值越小排越前面,这个约定要和前端定死。
  • status用于软性上下架。注意,分类的禁用状态和删除是两个概念——禁用只是前台不可见,数据还在;删除是物理移除。
  • create_user和update_user在设计时容易被忽略,但它们贯穿了整个项目的"操作日志"体系。后面做数据审计、排查谁改了数据时,这两个字段会救命。

1.3 为什么说分类管理是"承上启下"的模块

技术角度讲,分类管理是所有具备"树形或扇形展示"结构的业务模块的地基。你在它身上第一次接触到的几个通用能力:

  1. 通用的分页查询流程:前端传页码、每页条数、查询条件,后端用PageHelper或者手动拼LIMIT返回分页结果。这个模式在之后所有列表页里都会用到。
  2. 通用的状态流转模式:启用/禁用,本质上是对一条记录的单个字段做更新。这个模式在菜品管理、套餐管理里一模一样。
  3. 通用的删除保护模式:分类下如果挂了菜品/套餐,就不允许删除。这种"先检查关联数据再删"的套路,在之后所有主从表结构里都是核心逻辑。

所以半天时间学分类管理,并不亏。它把整个管理端的标准操作流程几乎都预习了一遍。

2. 管理端分类分页查询:最容易被小看的接口

2.1 接口约定与前端交互形式

管理端分类管理的界面,一般左侧是分类列表,右侧是当前分类下的数据。点开"分类管理"菜单,默认展示菜品分类,可以切换到套餐分类。页面底部是一套分页组件,可以跳页、改每页条数。

对应到后端接口,苍穹外卖中的分类分页查询接口标准定义大概是:

GET /admin/category/page 参数: name: 可选,按名称模糊查询 type: 可选,1菜品分类 2套餐分类 page: 页码,默认1 pageSize: 每页条数,默认10

这里有个容易忽略的点:前端切到"套餐分类"标签页时,并不会重新生成一个接口,而是复用同一个分页接口、多传一个type=2。所以你在后端没有必要单独写"菜品分类分页接口"和"套餐分类分页接口",做好type参数的可选处理即可。

2.2 Controller、Service、Mapper三层代码实现

先看Controller层。苍穹外卖用的是Spring Boot,标准结构是Controller收到请求后交给Service,Service再调用Mapper。代码大体这样:

@RestController @RequestMapping("/admin/category") @Slf4j public class CategoryController { @Autowired private CategoryService categoryService; @GetMapping("/page") public Result<PageResult> 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. 执行查询 List<Category> categoryList = categoryMapper.pageQuery(categoryPageQueryDTO); // 3. PageHelper会把总记录数放到PageInfo里 Page<Category> page = (Page<Category>) 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接口方法很简单:

List<Category> pageQuery(CategoryPageQueryDTO categoryPageQueryDTO);

对应的XML是真正的关键所在:

<select id="pageQuery" resultType="com.sky.entity.Category"> select * from category <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> <if test="type != null"> and type = #{type} </if> </where> order by sort asc, id desc </select>

几个细节,逐一说清楚:

第一,<where>标签自动处理掉多余的AND。当第一个条件不成立时,第二个条件的and会被MyBatis的<where>标签智能移除。如果我手动写成where 1=1再加条件,也能跑通,但看起来不够干净,<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在单请求下没问题,但并发时两个请求都查到count=0,然后都执行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?id=1

最核心的就是"分类下有菜品/套餐时不允许删除"。这个逻辑对应一条铁律:外键约束不够时,业务层必须显式校验。

先看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 id="countByCategoryId" resultType="java.lang.Integer"> select count(*) from dish where category_id = #{categoryId} </select>

dao层查询开销很小,因为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 参数:type(1菜品 2套餐)

Service实现:

public List<Category> list(Integer type) { return categoryMapper.listByTypeAndStatus(type, 1); }

Mapper查询:

<select id="listByTypeAndStatus" resultType="com.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 List<Category> list(Integer type) { String key = "set:category:" + type; String jsonStr = stringRedisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(jsonStr)) { return JSON.parseArray(jsonStr, Category.class); } List<Category> 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 test="name != null and name != ''">看起来很普通,但很多人栽在使用条件上。比如有人写成<if test="name != ''">,当name为null时,MyBatis判断null不等于空字符串,结果为true,照样拼了and name like concat('%',#{name},'%')。如果Java端传进来的是null,MyBatis一般能识别并生成like '%%',全表扫描还能查到,可一旦有的环境配置不同,就会出现异常或者查询出意外结果。规范写法就是name != null and name != ''两个条件都写上,不要偷懒。

6.3 日志和操作者信息要当成一等公民

我做分类接口时,在Service里加了@Log注解或者手动打印操作日志:

新增分类:type=1, name=主食, operator=3, time=2024-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判断生效
删除有套餐关联的分类报"分类关联套餐"异常套餐表计数判断生效
客户端列表只取启用分类只出现status=1的记录,按sort升序排序稳定,多次请求顺序一致
启用/禁用后客户端缓存清理客户端下一次请求拿到新列表缓存key按分类类型精确失效

每次改完代码我都跑一遍这张表,十五分钟不到,但省下的联调扯皮时间远超投入。分类管理这个模块做完,你对苍穹外卖整个项目的'操作类接口'手感就建立起来了。后面再做菜品管理、套餐管理,你会发现骨架几乎一样,区别只在业务校验的复杂度上。所谓基本功,就是把这种骨架做到肌肉记忆——不用看代码就能写出Service层该有的三件套:校验、业务逻辑、数据入库,且每个环节都不漏。

Day 2.5听起来像是课程进度的一半,但它其实是整个管理端开发真正的起点。把它吃透,后面的路会顺很多。

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

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

立即咨询