ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

苍穹外卖day03:Spring Boot分类管理模块CRUD实战与避坑指南

苍穹外卖day03:Spring Boot分类管理模块CRUD实战与避坑指南 1. 写在前面day03 到底做了什么跟着黑马这套苍穹外卖项目走到第三天刚好进入第一个完整 CRUD 模块——分类管理。前两天的员工登录、JWT 校验、Redis 存 token 这些骨架搭建完毕之后day03 开始用一套标准的三层架构把业务模块串起来新增分类、分页查询、修改分类、删除分类、分类启售禁售一共五个接口跑通了一个后台管理模块从接口设计到前后端联调的完整链路。这篇文章不打算复述课程视频里每一行代码而是把我在写分类管理模块时踩过的坑、绕过的弯路、以及后来对照源码才想明白的细节整理出来。如果你也正在跟着做苍穹外卖或者想通过一个真实完整的 JavaWeb 项目理解 Spring Boot 三层架构、MyBatis 操作、分页查询这些常见的面试热点这篇笔记应该对你有参考价值。分类管理看似简单但它决定了你对CRUD 模块这件事的理解深度后面几天的菜品、套餐管理都会复用这里的套路。2. 分类管理模块的需求拆解与设计思路2.1 分类在苍穹外卖业务中的位置先搞清楚分类这个模块在整套外卖系统里扮演什么角色。苍穹外卖分成用户端和管理端用户端就是小程序里点餐那一套管理端是商家运营后台。分类是菜品和套餐的挂靠单位——你在管理端左侧菜单里看到的菜品分类套餐分类就是这里管理的。具体来说分类表里用 type 字段区分两种类型type1 是菜品分类比如热菜凉菜主食type2 是套餐分类比如超值单人餐双人分享餐。为什么要分开因为菜品管理和套餐管理都要引用分类如果不区分类型用户在点餐界面看到的分类就会混在一起。这个 type 字段在后续新增菜品、新增套餐时都会作为下拉框的数据来源所以分类管理的 CRUD 写好了后面几天能省很多事。还有一个关键点分类有启售/禁售状态。status1 表示启用用户端能看到这个分类status0 表示禁用用户端看不到。这个状态和菜品的起售状态是联动的面试的时候经常被问到禁用分类时要不要顺带禁用下面的菜品这个问题没有标准答案关键看产品设计但至少你要能说出自己项目的处理方式。苍穹外卖这里处理得比较保守只是禁掉分类本身菜品在用户端会被隐藏因为菜品展示是按分类维度刷新的。2.2 五个接口的整体梳理day03 的核心就是下面这张接口清单接口请求方式路径说明新增分类POST/admin/category添加菜品分类或套餐分类分类分页查询GET/admin/category/page支持按分类名和类型过滤删除分类DELETE/admin/category?idxx按 id 删除修改分类PUT/admin/category更新分类名称、排序等启用禁用分类POST/admin/category/status/{status}修改分类状态注意所有路径都以 /admin 开头这是管理端接口的统一前缀同时被拦截器保护着请求进来先过 JWT 校验token 合法才放行。所以 Controller 里不需要每个接口都写身份校验拦截器已经统一处理了。从这五个接口能看出一个完整的后台管理模块长什么样增、删、改、查、状态切换一个不少。之后 day04 的菜品管理、day05 的套餐管理其实都是在这套模板上再加自己的业务逻辑。所以我不建议你把分类管理当成一个孤立的小模块糊弄过去它是后面所有管理模块的范式值得认真写一遍。2.3 技术选型为什么这么分层苍穹外卖的项目结构分成 Controller、Service、Mapper 三层这是 JavaWeb 开发最经典的分层。我一直觉得课程里最值得学的不是某个注解的用法而是这种分层思维的养成。Controller 只负责接收参数和响应结果不写业务逻辑Service 负责业务规则比如新增分类前检查分类名是否重复、删除分类前检查是否有菜品引用Mapper 只负责和数据库打交道。课程新版用的是 MyBatis-Plus这个选择确实让学习曲线平缓很多。比如分页查询MyBatis-Plus 自带分页插件只需要配置一个拦截器然后 new Page(pageNum, pageSize) 传进去查询完直接从 page 对象里拿 total 和 records。如果是老版的 Spring Boot 2 原生 MyBatis PageHelper 的写法原理也是一样的只是 API 不同。我建议你最好把两种方式的差异都了解一下因为面试官很可能拿分页原理来问你核心是搞清楚它底层是执行了一条带 LIMIT 的 SQL然后再用一条 COUNT 查询拿总数。分层的好处是后期好维护。以后你接手真实项目会发现业务逻辑越来越多如果全堆在 Controller 里一个方法可能几百行根本没法看。苍穹外卖从小就在教你责任分离这个习惯比任何框架知识都值钱。3. 核心实现细节拆解3.1 实体类与表结构分类表 category 的字段如下字段类型说明idbigint主键自增typeint分类类型1 菜品分类2 套餐分类namevarchar分类名称sortint排序值越小越靠前statusint状态1 启用0 禁用create_timedatetime创建时间update_timedatetime更新时间create_userbigint创建人 idupdate_userbigint修改人 id对应的实体类 Category 用 Lombok 的 Data 标注字段类型用 Long、Integer、String、LocalDateTime 这些包装类型。注意 create_user 这种下划线命名MyBatis-Plus 默认开启驼峰映射会自动把 createUser 映射到 create_user不需要手写 resultMap。如果你用的是老版原生 MyBatis记得在 XML 里写 resultMap 或开启 mapUnderscoreToCamelCase 配置否则查出来 createUser 一直是 null排查起来很耗时间。关于 createUser/updateUser 这两个字段我第一次写的时候觉得有点冗余——分类表为什么要存用户 id后来才明白这是审计字段记录谁在什么时候改了什么。真实项目里这种字段几乎是标配方便出问题时追溯责任人。day02 做员工管理的时候ThreadLocal 里就存了当前登录员工的 id这里新增或修改分类时把这些字段填上就行。3.2 Controller 层怎么写才算规范分类管理的 Controller 我贴一段核心代码RestController RequestMapping(/admin/category) public class CategoryController { Autowired private CategoryService categoryService; PostMapping public ResultString save(RequestBody Category category) { categoryService.save(category); return Result.success(); } GetMapping(/page) public ResultPageResult page(int page, int pageSize, String name, Integer type) { PageResult pageResult categoryService.pageQuery(page, pageSize, name, type); return Result.success(pageResult); } DeleteMapping public ResultString deleteById(Long id) { categoryService.deleteById(id); return Result.success(); } PutMapping public ResultString update(RequestBody Category category) { categoryService.update(category); return Result.success(); } PostMapping(/status/{status}) public ResultString startOrStop(PathVariable Integer status, Long id) { categoryService.startOrStop(status, id); return Result.success(); } }几点说明RestController 是 Controller ResponseBody 的组合所有方法返回值直接序列化成 JSON。Result 是统一结果类里面封装 code、msg、data 三个字段前端根据 code 判断请求是否成功。这套东西是课程里提前提供的后面所有模块都复用它。PostMapping 新增和 PutMapping 修改都用了 RequestBody 接收 JSON 对象。前端请求体是 JSON 格式不加 RequestBody 的话Spring 没法把 JSON 字段绑定到 Category 对象上这是新手最容易犯的错。删除接口的路径没有带 {id}而是用 ?idxx 的查询参数方式所以方法参数直接 Long id 就行。启停接口把 status 放在路径里用 PathVariable 拿id 放在查询参数里。接口这么设计是固定的照着前端页面的请求方式对齐即可。3.3 Service 层业务规则放在这里Service 接口定义好方法实现类用 Service 标注。分类模块的业务逻辑主要是三块新增时校验名称是否重复、删除时校验是否关联了菜品或套餐、修改或启停时更新操作人和时间。新增分类Override public void save(Category category) { category.setStatus(1); // 新增默认启用 category.setCreateTime(LocalDateTime.now()); category.setUpdateTime(LocalDateTime.now()); category.setCreateUser(BaseContext.getCurrentId()); category.setUpdateUser(BaseContext.getCurrentId()); categoryMapper.insert(category); }这里有个细节新增时你没有传 status数据库默认值也可以处理但课程选择在代码里显式 setStatus(1)这样可读性好别人看你代码一眼就知道新分类默认是启用的。createUser 从 BaseContext 里取BaseContext 就是基于 ThreadLocal 封装的一个工具类登录拦截器在 JWT 校验通过后把当前用户 id 放进去这样业务层随时能拿到当前操作人。3.4 Mapper 层与分页实现分类的 Mapper 很简单继承 BaseMapper 就有 insert、updateById、deleteById、selectPage 这些现成方法。重点看分页查询Override public PageResult pageQuery(int page, int pageSize, String name, Integer type) { PageCategory pageInfo new Page(page, pageSize); LambdaQueryWrapperCategory queryWrapper new LambdaQueryWrapper(); queryWrapper.eq(name ! null !name.isEmpty(), Category::getName, name) .eq(type ! null, Category::getType, type) .orderByAsc(Category::getSort); categoryMapper.selectPage(pageInfo, queryWrapper); return new PageResult(pageInfo.getTotal(), pageInfo.getRecords()); }LambdaQueryWrapper 是 MyBatis-Plus 提供的条件构造器eq 方法的第一个参数是 boolean为 true 才拼接这个条件这样就能优雅地处理name 传不传都可以的过滤需求。orderByAsc(Category::getSort) 是说按 sort 升序排列sort 越小越靠前前端拖拽排序就是改这个字段。新手经常忽略的坑Page 分页查询必须配置 MybatisPlusInterceptor 分页插件否则 selectPage 查出来的 total 是 0、records 只有一页的数据实际是把所有数据查出来在内存里截断。这个配置加一个配置类即可Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }不加这个拦截器分页 SQL 就不会被改写这是 day03 最经典的问题十个人里有八个会踩。4. 实操过程五个接口的完整实现4.1 新增分类别漏了默认状态和审计字段前端添加分类弹窗里会填名称、类型菜品/套餐、排序值提交的时候是 JSON 格式。服务端拿到对象后name 和 type 是前端传的status 之类是服务端自己补的。有个容易被忽略的点前端可能不会传 sort但数据库 sort 字段如果没默认值就会插入失败。稳妥的做法是在实体类里给 sort 设置默认值 0或者新增接口里判断一下。课程代码里前端是传了 sort 的但你做的时候最好做个兜底判断养成习惯。新增完成之后用前端页面验证一下打开管理端分类页面切到菜品分类tab点添加输入名称提交列表第一页应该能刷新出来。如果列表没变化优先去后端控制台看有没有报错而不是盲改前端。4.2 分页查询多条件查询的条件拼接分页接口是 day03 里最需要耐心调试的一个。前端分类页面有两个 tab切到菜品分类时分页请求带了 type1切到套餐分类时带 type2搜索框输入文字时带 name 参数。我在调试时用 IDEA 里 Swagger 或者直接浏览器访问试过几种参数组合/admin/category/page?page1pageSize10/admin/category/page?page1pageSize10type1/admin/category/page?page1pageSize10name热菜三种情况后端都能正常返回。如果 name 传了但查不到数据先确认前端传的编码是否正常中文参数经过 URL 传递要保证是 UTF-8。Spring Boot 默认配置一般没问题但如果你改过 server.servlet.encoding 相关配置就可能翻车。4.3 删除分类先查后删别让脏数据教做人删除接口是 DELETE /admin/category?idxx。如果直接调用 categoryMapper.deleteById(id)看起来没问题但等你在管理端界面删一个正在被菜品引用的分类时数据库层面如果没有外键约束可能就删成功了留下孤儿菜品如果有外键约束就直接报错给你看。所以删除分类前的业务校验很重要查一下这个分类下有没有菜品或套餐有的话提示当前分类下关联了菜品不能删除。这个校验逻辑在 Service 层做需要注入 DishMapper 或 SetmealMapper 的查询方法。实际代码类似public void deleteById(Long id) { Long dishCount dishMapper.countByCategoryId(id); if (dishCount 0) { throw new DeletionNotAllowedException(当前分类下关联了菜品不能删除); } categoryMapper.deleteById(id); }注意这里抛出的异常是项目里自定义的业务异常全局异常处理器会捕获它并返回给前端。苍穹外卖是自带 GlobalExceptionHandler 的day01 就见过。如果你的项目报 500 而不是友好的提示信息大概率是异常没被捕获或抛错了类型。4.4 修改分类updateTime 必须刷新修改分类接口接收整个 Category 对象前端把要改的字段都传回来。Service 层处理时有个很容易犯的错直接把前端传的对象 updateById结果 createTime 和 createUser 被覆盖或者变成 null。正确做法是只更新业务字段name、sort、type 等然后单独设置 updateTime 和 updateUser。MyBatis-Plus 的 updateById 有个特性实体类里为 null 的字段不会出现在 UPDATE SET 子句中所以 createTime 传 null 反而不会覆盖原值但这个问题依然值得注意因为你无法保证前端不会传一个旧值回来。最稳的方案是public void update(Category category) { category.setUpdateTime(LocalDateTime.now()); category.setUpdateUser(BaseContext.getCurrentId()); categoryMapper.updateById(category); }前端传的 category 里只有 id、name、sort、type后端补 updateTime 和 updateUsercreateTime 保持数据库原值不动。4.5 启用/禁用分类路径参数和查询参数的区分启停接口POST /admin/category/status/1?idxx。status 在路径里id 在查询参数里。Controller 里用 PathVariable Integer status 和 Long id 接收。Service 里构造一个 Category 对象public void startOrStop(Integer status, Long id) { Category category Category.builder() .id(id) .status(status) .updateTime(LocalDateTime.now()) .updateUser(BaseContext.getCurrentId()) .build(); categoryMapper.updateById(category); }用 builder 构造只含 id、status 和审计字段的对象updateById 时其他字段不参与更新这个手法在真实项目里很常见。为什么不直接用传进来的 status 覆盖整个实体因为前端只传了 status 和 id直接 updateById 一个完整实体反而可能更新掉不该更新的列。4.6 前后端联调的检查点写完后端接口后用浏览器开发者工具看 Network 请求是最快的验证方式。几个检查点请求 URL 是否带上了 /admin 前缀token 是否在 Header 里Authorization: Bearer xxxPOST、PUT 请求的 Content-Type 是不是 application/json响应里的 code 是否为 1苍穹外卖里 1 表示成功0 表示失败分页接口返回的 total 和 records 是否完整课程提供的管理端前端是 Vue3 项目本地开发时做了 Vite 代理把 /admin 开头的请求转发到后端 8080 端口。如果你自己配前端遇到跨域问题记得配置 CORS 或代理别在后端瞎加 CrossOrigin全局配置更规范。5. 常见问题与排查技巧实录5.1 分页查出来的 total 永远是 0这是我 day03 踩过最大的坑折腾了半个多小时。最后发现项目里根本没有配置分页拦截器。MyBatis-Plus 的 selectPage 依赖 PaginationInnerInterceptor不配置它就相当于只查了 LIMIT 那一段数据total 自然不会正确统计。排查方法也很简单控制台打印 SQL如果只看到一条带 LIMIT 的查询而没有 SELECT COUNT基本就是拦截器没生效。检查顺序配置文件里是否引入了 mybatis-plus 依赖 - 是否定义了 MybatisPlusInterceptor Bean - 拦截器里是否 addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL))。5.2 删除分类为什么前端一直提示操作失败我在测试时用 Postman 直接调删除接口返回 200 和 code1但前端界面点删除就失败。后来发现是前端弹出确认框后请求 URL 里拼接的参数名不对。前端代码里删除接口用的是DELETE /admin/category?id${id}后端参数名如果叫 id 就对得上如果你自己改名叫 categoryId两边就对不上了。接口参数命名这种事务必以页面实际发出的请求为准不要自嗨。前后端分离项目的联调本质就是对齐约定这个习惯越早养越好。5.3 新增分类后列表里 status 字段显示异常新分类在管理端列表里状态列要么显示禁用要么按钮状态不对。检查后端 save 方法里是否设置了 status1。如果你直接信任数据库默认值而表结构里 status 又没有 default 1那插入的就是 null前端拿到 null 去判断就乱套。建议表层面也设置默认值双保险。这样哪怕代码漏了数据也不会是脏的。同理create_time 和 update_time 也建议在数据库加 DEFAULT CURRENT_TIMESTAMP代码里再显式设置一次两边都兜底。5.4 中文分类名乱码或查询不到老版项目如果在 JDBC URL 里没配编码往数据库写入中文就会乱码。新版用 Spring Boot 3 一般默认不会出这个问题但如果数据库连接串是手写的记得加上jdbc:mysql://localhost:3306/sky_take_out?useUnicodetruecharacterEncodingutf8还有一个容易被忽略的IDEA 控制台显示乱码不代表数据库里是乱码要以数据库客户端查询结果为准。有时候只是控制台编码问题虚惊一场。5.5 后端改了代码但前端看不到效果这个不算分类模块特有的坑但往往在联调阶段频繁出现。后端改了 Service 逻辑后前端怎么刷新都没变化最后发现是 IDEA 没有热部署后端根本没重启。用 DevTools 或手动重启一下顺便养成看控制台日志的好习惯——每次请求进来日志会打印 SQL 和参数一眼就能看出问题出在哪一层。如果日志里能看到请求进来但响应不对问题在业务层如果日志里根本没这个请求问题在网络层或拦截器如果 SQL 打出来了但结果不对问题在 SQL 语句本身。按这个思路排查效率会高很多。6. 一点个人体会写完分类管理这五个接口我对 Spring Boot 项目的整体认知明显比前两天清晰了。以前看网上教程总是被各种注解绕晕真正跟着项目把 Controller-Service-Mapper 这条链路亲手搭一遍才知道每个注解是干嘛的、为什么放在那里。个人建议学这个项目别只盯着视频敲代码每完成一个模块自己试着不看源码把接口重写一遍然后对比差异。我在写分类管理的时候第一遍照着视频敲第二遍自己默写卡在 LambdaQueryWrapper 的 eq 条件拼接上正是这个卡壳的过程让我真正记住了它的用法。默写代码比抄代码效率高太多了哪怕写出来的有错也是带着问题去改印象会特别深。后面 day04 的菜品管理会在分类管理基础上加入文件上传和 Redis 缓存day05 的套餐管理又会引入多表操作分类模块练熟的三层架构套路后面会越用越顺。如果你也在学苍穹外卖遇到具体问题欢迎交流有些坑两个人讨论比自己闷头查快得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进