1. 内容整体设计与思路拆解
很多人学苍穹外卖,前面几天多少有点"照着敲"的感觉,环境装好、登录写好、分类管理跑通,一切都像既定的流程。但到了第6天,这个项目才真正开始有"业务系统"的样子。day6的核心是菜品管理、套餐管理,外加一个贯穿其中的文件上传功能,这套组合拳打下来,你会发现所谓后端CRUD,根本不是"对着表增删改查"那么轻松。
先说清楚day6的位置。如果你按天推进这个项目,前面几天的铺垫大概是:第1到3天完成基础环境(MySQL、Redis、Nginx、Maven私服仓库),以及员工登录和JWT鉴权;第4到5天做员工管理、分类管理,顺带引入公共字段自动填充这类提效手段。到第6天,业务开始从"单独一张表"走向"多张表联动",菜品和口味、套餐和菜品,都是经典的一对多、多对多关系。这一步走顺了,后面订单、购物车那些复杂模块才有底气。
day6要解决的核心问题,往深了说有三个层面。第一,一个真实的业务对象往往不是一张表能装下的。一个菜品除了自身的基本信息,还挂着一串口味列表,一个套餐又关联着多个菜品,这种"主表+从表"的结构,是所有业务系统的常态。第二,后台管理的数据结构和前端展示的数据结构并不一致,你不能把数据库表结构直接丢给前端,中间需要DTO和VO做隔离。第三,文件上传不是独立功能,它是菜品和套餐模块的"配套设施",没有图片的菜品管理页面,约等于没有灵魂。
从模块划分来看,day6的后端代码依然是标准的Controller-Service-Mapper三层,但这一天的代码量会明显上一个台阶。Controller层只管接收参数、调用服务、封装结果,诀窍在于参数对象设计——别用实体类接收前端请求,而是用DTO。例如新增菜品时前端传来的DishDTO里包含了flavors列表字段,dish实体里根本没有这个属性,如果硬塞进去,实体类会被"污染",后续MyBatis的自动映射也会出问题。Service层做真正的业务编排,校验、转换、落库、联动,全在这里发生。Mapper层就是纯粹的SQL执行,越单纯越好。
这个分层思路,说白了就是把"做什么"和"怎么做"分开。Controller知道接口要干什么,Service知道业务怎么编排,Mapper只知道数据怎么存取。day6如果能把这种分层意识建立起来,后面写一万行代码都不会乱。我见过不少新手把SQL写在Controller里,当时能跑,改需求时哭都来不及。
1.1 day6之前的铺垫与知识点衔接
day6不是一个孤立的日子,它把前几天的技能点全部串了起来。JWT登录拦截器在这个阶段会再次出场,因为菜品管理接口同样需要管理员身份校验;公共字段自动填充(就是那些create_time、update_time、create_user、update_user字段)也是从这里开始频繁使用的。如果你前几天偷懒没实现自动填充,day6你会在每一次新增和修改的代码里反复set这4个字段,写20次之后你就会回来补课的。
另外,员工管理和分类管理的单表CRUD给day6打了个底。菜品管理看起来也就是CRUD,但多了口味子表、多了图片上传、多了启售停售状态流转,复杂度是几何级上升的。我在实际带新人的时候,最怕的就是学员把day6当成"又一个增删改查",上来就闷头写,写到最后发现菜品删不掉、口味更新不了、套餐在售但关联菜品已停售,这些坑全部踩一遍才回头理解设计的重要性。
1.2 day6核心需求拆解:从单表操作到多表联动
我们不妨用一句话概括day6的需求:让运营人员可以在后台维护菜品和套餐信息,包括新增、修改、删除、查询、启售停售,并且菜品和套餐都要能带图片、带口味、带关联数据。
拆解下来核心需求有三块。菜品管理,这是最重的模块,涉及菜品主表和口味子表的数据一致性,什么时候该插入子表、什么时候该删除子表、什么时候只更新主表,每个操作都要想清楚。套餐管理,这个模块更绕,因为一个套餐包含多个菜品,而套餐的价格、描述和关联菜品是分离的,新增一个套餐时,后端要做两件事:先插入套餐主表拿到自增ID,再批量插入套餐菜品关联表。文件上传,为什么单独拎出来说?因为它在业务上相对独立,但技术上牵扯到静态资源映射、拦截器放行、统一路径返回,任何一个环节漏了,前端图片就显示不出来。
day6之后,你应该形成一种肌肉记忆:凡是接口参数里有List的,先想想这个List的数据怎么落库;凡是页面有图片的,先确认一下文件上传接口有没有被登录拦截器挡掉;凡是修改数据的,先想想有没有"先删子表再插子表"这种常规操作。这些都是day6的隐藏考点。
2. 菜品管理模块的核心实现细节
菜品管理模块是day6的"正餐",来,我把整个实现过程掰开揉碎讲一遍。
2.1 表结构设计与实体类映射
先看表结构。菜品主表dish的核心字段有:
- id:主键,自增
- name:菜品名称,业务上要求唯一
- category_id:所属分类,外键逻辑关联分类表
- price:价格,注意数据库里用decimal,单位是元
- image:图片路径,存的是相对路径,例如/upload/xxx.jpg
- description:描述信息
- status:售卖状态,1起售0停售
- create_time、update_time、create_user、update_user:4个公共字段
菜品口味表dish_flavor的字段就简单了:
- id:主键
- dish_id:关联菜品主表
- name:口味名称,比如"辣度"
- value:口味可选值,比如"不辣,微辣,中辣,特辣"
实体类设计上,dish实体对应主表,dish_flavor实体对应口味表,关键一点是在Dish实体里加一个List\u003CDishFlavor\u003E flavors字段,用于承载一对多的关系。这个字段在数据库里没有对应列,但是MyBatis查询时可以通过collection标签映射出来,写代码时也方便对象导航。
我在实际写这个模块的时候,曾经纠结过要不要单独建一个DishVO用于分页查询展示,后来发现非常有必要。因为分页列表里的数据来自三张表:菜品主表、分类表(需要分类名称)、口味表(列表页可能需要展示口味数量),直接用Dish实体接收,还是得额外加categoryName属性,不如建一个专门的VO,从Mapper层就select出所有展示需要的字段,Controller层直接返回,干净利落。
2.2 新增菜品时的双表落库逻辑
新增菜品的接口路径是POST /admin/dish,请求参数用DishDTO接收,里面包含了菜品基本信息 + List\u003CDishFlavor\u003E口味列表。代码逻辑分三步走。
第一步,把DTO的属性拷到Dish实体上。这一步可以用Spring的BeanUtils.copyProperties,但要注意DTO里的status字段前端可能不传,后端要默认置为1(起售状态),不然新菜品默认是停售的,运营还得手动起售一次,体验很不好。
第二步,往dish表插入主数据,MyBatis的useGeneratedKeys属性会帮我们把自增主键回填到Dish对象的id属性上。这是整个双表落库的关键,没有这个ID,后面的口味数据根本没法关联。
第三步,遍历DishDTO里的flavors列表,给每个dishFlavor对象setDishId成刚才回填的ID,然后批量插入dish_flavor表,一条SQL搞定。
这段逻辑看起来不难,但有一个很重要的细节:事务。主表和从表的数据操作必须放在一个事务里,要么都成功,要么都失败。我见过有人把两步写在一个方法里但没加@Transactional注解,结果主表插入成功、口味插入失败,数据库里出现一条没有口味的"孤儿菜品",后面修改菜品时再加载这个孤儿数据还会报错。正确的做法是在Service方法上标注@Transactional,并且确保方法内部抛出的异常是RuntimeException,Spring默认只对运行时异常回滚,受检异常是不会触发回滚的。
2.3 修改菜品时的联动更新策略
修改菜品比新增更隐蔽,因为你要决定口味列表怎么办。举一个最常见的坑:前端提交修改时,把原来三个口味改成了两个口味,如果你只做了"删除全部口味再重新插入",那没问题;但如果你只update了口味记录,删掉的那个口味会在数据库里残留,界面不显示、数据库里却有脏数据。
行业里最常规的写法就是"先删后插":按照dishId先delete掉dish_flavor表中所有记录,再批量插入新的口味数据,整个过程依然在一个事务里。这样写的好处是逻辑简单、不容易出错,代价是每次修改都要删除再插入,但口味数据量很小,性能损失可以忽略。
我自己的心得是,修改菜品时还要注意更新update_time和update_user字段。如果你已经实现了公共字段自动填充,那这个操作是切面自动完成的;如果没实现,这里最容易漏掉的就是update_user,前端传参里根本没有这个字段,只能通过BaseContext从当前登录线程里取。
2.4 启售停售菜品的状态管理
启售停售接口本身很简单:UPDATE dish SET status = #{status} WHERE id = #{id}。但如果你稍微多思考一步,启售停售会对套餐产生影响。一个套餐关联了多个菜品,如果其中有一个菜品是停售状态,这个套餐理论上不应该再向用户展示,因为用户下单后商家根本做不出来这道菜。
所以day6如果时间充裕,建议把套餐状态和菜品状态的联动逻辑一并想清楚。最简单的方案是:查询套餐时,如果关联菜品存在停售,就把套餐标记为停售。这个逻辑放到Service层去判断,不要放到SQL里硬写,因为SQL里查关联状态再关联更新,写起来很别扭,而且容易产生并发数据不一致。
3. 文件上传与图片回显的实操细节
文件上传在day6里看着是个小功能,其实是一个"看起来简单但处处是坑"的模块。我会把整个链路拆开讲,从接口编写到静态资源映射,把容易踩的坑全部指出来。
3.1 上传接口的编写思路与文件命名规则
苍穹外卖的上传接口路径是POST /admin/common/upload,接收参数名叫file,类型是MultipartFile。后端处理流程分四步。
- 判断文件是否为空,为空直接抛业务异常
- 获取原始文件名,截取出扩展名,比如.jpg、.png
- 用UUID生成新文件名,拼接扩展名,避免文件名冲突
- 把文件保存到本地磁盘指定目录,返回可访问的URL路径
这里有一个很重要的点:为什么要用UUID重命名?因为运营上传的图片很可能重名,如果直接存原名,第二次上传同名图片会把第一次的覆盖掉,而且文件名带中文在部分服务器上还会出现编码问题。用UUID重命名,虽然最终文件名人眼识别不了,但结合数据库里的记录,完全可以追溯。
本地存储目录我建议配置成项目外的绝对路径,比如/usr/local/upload/。不要放在项目的resources目录下面,因为打包部署后resources目录在jar包里,文件写入会直接报错。也不要放在target目录下,因为每次重新编译发布,target目录会被清空,上传的图片就全没了。
3.2 登录拦截器的放行规则与静态资源映射
这是day6里最典型的"坑王"问题。苍穹外卖的拦截器默认拦截所有/admin/**的请求,如果你把上传接口写好了但没配放行,前端发起上传请求时会直接被拦截器拦下,返回"未登录"或"登录已过期",而前端若没做统一错误处理,你会看到上传进度转了一圈又弹回错误提示,完全不知道发生了什么。
解决方式就是拿着WebMvcConfiguration类,在addInterceptors方法里给admin拦截器加excludePathPatterns,把/admin/employee/login和/admin/common/upload放行。登录接口放行很好理解,上传接口放行是因为上传请求的header里可能没带token,或者带了token但已经过期,运营手动上传图片时不应该被鉴权阻塞。
文件上传之后,图片路径的访问又是一个独立的静态资源映射问题。你在Controller里返回的路径是/upload/xxx.jpg,但浏览器访问http://localhost:8080/upload/xxx.jpg时,Spring MVC默认不会把这个路径映射到磁盘目录。你需要在addResourceHandlers方法里做一次映射:registry.addResourceHandler("/upload/**").addResourceLocations("file:/usr/local/upload/")。这里的file:前缀表示读取本地磁盘资源,路径末尾的斜杠不能丢,丢了会映射失败,404没商量。
3.3 图片回显路径的常见问题与规范化处理
前面说了文件要存到本地磁盘,接口返回的路径是/upload/xxx.jpg。这里有一个很隐蔽的问题:前端拿这个路径去请求图片时,如果前端项目和后端接口部署在同一个域名和端口下,那直接拼IP和端口就能访问;但如果前后端分离部署,前端在8081端口,后端在8080端口,前端的img标签直接写相对路径/upload/xxx.jpg,浏览器会去请求8081端口的资源,等于请求了一个不存在的东西。
更稳妥的做法是,上传接口返回给前端的直接是完整的URL,例如https://你的域名/upload/xxx.jpg。这样不管前端部署在哪,图片路径都能被正确解析。实际开发中,很多团队会用Nginx做反向代理,把/upload路径代理到后端的本地磁盘目录,前端拿到的还是相对路径,但浏览器实际请求的是Nginx的地址,Nginx再从磁盘把图片捞出来返回。
不要在前端代码里写死localhost:8080这样的地址,这种代码在本地跑通了,一旦部署到服务器就要全部改一遍。我的建议是,前后端的接口访问都统一走一个可配置的基础URL,图片路径同样拼接在这个基础URL下面,环境切换时只需改一处配置。
4. 套餐管理模块的复杂业务逻辑
套餐模块在day6里往往只有一两天的时间,但它的逻辑复杂度比菜品管理高一个档次,因为它牵扯到三张表的联动:套餐主表setmeal、套餐菜品关联表setmeal_dish、菜品表dish。
4.1 新增套餐时主表和关联表的保存顺序
套餐主表的字段包括id、name、category_id、price、status、description、image等,和菜品表结构非常相似。关键差异在setmeal_dish表,它记录了套餐和菜品的多对多关系,字段有id、setmeal_id、dish_id、name、price。注意,在设计套餐菜品关联表时,通常会冗余一份菜品的name和price字段,这样套餐页面展示时不需要去join菜品表就能直接显示菜品名称和价格,查询效率更高,这就是典型的"以空间换时间"的冗余设计。
新增套餐的SQL顺序是:先插入setmeal主表,拿到回填的主键ID,然后遍历前端传来的setmealDishes列表,给每个SetmealDish对象setSetmealId,最后批量插入关联表。逻辑比菜品新增多了一层,但原理完全一致。
但这里有一个容易被忽略的校验逻辑:新增套餐时,前端传来的关联菜品必须是"存在且处于起售状态"的菜品。如果某个菜品已经停售了,这个套餐就算建出来,用户下单选了这个套餐,商家也没法做菜。所以Service层在插入之前要做一次校验,查出所有关联菜品的ID,检查它们的status是否都为1(起售),如果有任何一个停售,直接抛异常拒绝保存。
4.2 修改、删除套餐时的数据一致性处理
修改套餐同样采用"先删后插"的策略处理关联菜品表:先DELETE FROM setmeal_dish WHERE setmeal_id = ?,再重新批量插入新的套餐菜品关系。主表信息用UPDATE语句更新,整体包在一个事务里。
删除套餐就更有讲究了。因为setmeal_dish表通过setmeal_id关联主表,如果你先删主表记录,关联表里的数据就会变成孤儿数据。所以正确顺序是:先删除关联表记录,再删除主表记录。当然,更推荐的做法是在删除前先查一下这个套餐是否处于"起售"状态,起售状态的套餐不能删除,必须先把套餐停售了才能删。这个校验逻辑可以防住运营误操作,前端页面也建议同步处理:起售状态时禁用删除按钮。
让我再多说一句事务失效的问题。在Spring里,@Transactional注解是放在Service方法上的,但如果Service类内部有一个方法A调用了同类中的方法B,B上的@Transactional注解不会生效,因为Spring的声明式事务是基于代理机制的,B被A调用时走的是this引用,没有经过代理对象。所以我的习惯是,需要事务的代码尽量独立成方法,或者保证入口方法持有@Transactional注解。
4.3 缓存联动:启售停售时的状态一致性
有些版本的苍穹外卖到day6会引入Redis缓存用户端的菜品和套餐列表。核心思路是:用户端查询菜品或套餐时,先查Redis缓存,缓存没有再去查数据库,并把结果写入缓存。这里最大的坑是缓存和数据库的一致性问题。
最简单的解决方案是"Cache Aside Pattern":更新数据库之前,先删除对应的Redis缓存,等数据库更新成功后,下次查询再把数据写入缓存。这样虽然做不到严格一致,但可以保证最终一致性。如果你在day6阶段就引入了缓存,请务必记住,启售、停售、修改、删除菜品或套餐后,要主动清理对应的缓存key,不然用户端看到的还是旧数据。
我见过一个真实的生产事故,运营在后台把某个菜品价格从30改成25,用户端因为缓存没过期,仍然显示30。运营以为是后端没改,反复提Bug,最后排查到是缓存没清。这类问题在day6埋下种子,到了后面订单模块会持续发酵。
5. 常见问题与排查技巧实录
这一节把我在带这个项目时真实遇到过的典型问题整理成表,方便你对号入座。
5.1 高频问题速查表
| 问题现象 | 排查思路 | 解决办法 |
|---|---|---|
| 前端上传图片时请求被拦截,返回401或未登录 | 检查拦截器放行路径有没有包含/admin/common/upload | 在WebMvcConfiguration中放行上传接口 |
| 图片上传成功,但浏览器访问图片地址404 | 检查是否配置了静态资源映射/upload/**到磁盘路径 | 在addResourceHandlers中配置file:映射 |
| 新增菜品后页面列表看不到口味数据 | 检查口味表是否插入了数据、dish_id是否正确回填 | 确认useGeneratedKeys="true"和keyProperty="id"配置无误 |
| 修改菜品后口味数据不更新 | 检查Service方法是否写了先DELETE再批量INSERT的逻辑 | 先按dish_id删除原口味,再插入新口味 |
| 删除套餐时报外键约束错误 | 检查删除顺序是否先删关联表再删主表 | 调整为先delete setmeal_dish再delete setmeal |
| 返回给前端的日期时间是一串数字 | LocalDateTime序列化格式问题 | 配置Jackson消息转换器,指定日期格式为yyyy-MM-dd HH:mm:ss |
| 修改数据时update_user字段没有更新 | 检查公共字段自动填充切面是否覆盖了UPDATE操作 | 在切面中通过OperationType判断是INSERT还是UPDATE,分别填充不同字段 |
| 套餐在售状态,但关联菜品已经停售 | 缺少套餐状态与菜品状态联动校验 | 在查询和修改逻辑中检查关联菜品状态,存在停售菜品则标记套餐停售 |
5.2 时间格式与JSON序列化的经典问题
day6的接口开始返回带时间的列表数据,很多前端会向你抱怨"时间字段显示成一大串数字"。这是Java 8的LocalDateTime在Jackson默认序列化下的表现,它会被序列化成类似[2024, 12, 25, 14, 30, 0]的数组,前端根本没法直接展示。
解决办法有几个方案,最推荐的是实现一个Jackson的ObjectMapper定制,在序列化LocalDateTime时统一输出为yyyy-MM-dd HH:mm:ss格式。如果你用的是Spring Boot,可以自定义一个WebMvcConfigurer,手动添加MappingJackson2HttpMessageConverter,并在ObjectMapper中注册JavaTimeModule,关闭WRITE_DATES_AS_TIMESTAMPS。
也有一些项目会采用在application.yml里配置spring.jackson.date-format的方式。这里有个坑:spring.jackson.date-format默认只对java.util.Date生效,对LocalDateTime不一定有效。如果你配置后发现还是输出数组,就老老实实写一个自定义的ObjectMapper定制类,别在配置文件的表面上浪费时间。
5.3 事务回滚不生效的几个隐蔽原因
day6涉及大量双表操作,事务是最容易出问题的地方。我总结了三类典型原因。
第一类,异常被吞了。很多人在Service方法里写了try-catch,异常被catch住没往外抛,事务自然不会回滚。正确的姿势是:不要在事务方法里捕获异常,或者捕获后继续抛出RuntimeException。
第二类,方法自调用。前面已经说过,同类内部方法间调用时@Transactional不生效。如果你发现Service方法明明加了注解但没回滚,去检查一下是不是通过this调用的内部方法。
第三类,数据库表不支持事务。MySQL中InnoDB引擎才支持事务,有些初学者误建了MyISAM表,事务注解加了跟没加一样。排查时可以用SHOW TABLE STATUS确认表的引擎。
5.4 一个小而实用的调试建议
day6的调试工作,我强烈建议你把MyBatis的SQL日志打开。在application.yml里配置mybatis-plus.configuration.log-impl为org.apache.ibatis.logging.stdout.StdOutImpl,或者直接设置logging.level.你的mapper包路径为debug,这样每次执行SQL都能在控制台看到完整SQL和参数值。排查bug的效率提升一倍不止,你会清楚地看到插入主表、回填ID、批量插入子表的完整流程,也能一眼看出哪条SQL的参数是null。
调试双表操作时,更推荐的做法是开启Spring事务管理的debug日志,这样你能看到事务什么时候开启、什么时候提交、什么时候回滚。一旦发现事务没回滚,日志里会给出很明确的线索。
写在最后的几点实在建议
day6这个节点,很多人的学习曲线会出现第一个明显的分水岭。前面几天的代码都是照着视频或文档敲,敲完就过了,但到菜品和套餐这两个模块,光"会敲"已经不够了,你得"想明白为什么这么敲"。我见过太多人把代码敲完、页面跑通就觉得自己学会了,问他"修改菜品时口味为什么要先删后插",答不上来。这个问题从day6一直能追问到面试现场,而且被问到的概率极高。
我自己的感受是,day6最大的价值不是让你多会几个接口,而是让你建立起"主表从表联动"的直觉。有了这个直觉,后面学习购物车、订单、用户端菜品展示,你会觉得万变不离其宗。反过来,day6如果学得囫囵吞枣,后面越学越吃力,那几乎是必然的。
最后分享一个小技巧:学这个项目时,不要复制粘贴代码,哪怕视频里给了现成的,也请你手动敲一遍。敲的过程中你会被迫面对每一个细节,比如useGeneratedKeys写在哪个位置、批量插入的foreach标签怎么写的、拦截器放行路径的斜杠有没有加对。这些细节才是真正的经验积累。另外,敲完一个功能后,建议打开浏览器的开发者工具,实际看一次前端发起的请求参数和后端返回的JSON结构,把整个数据流在脑子里过一遍,比多敲三遍印象都深。