苍穹外卖这个项目,不少学Java的同学都拿它当实战练手。我当年从0到1撸完整个项目的时候,做过用户端小程序、管理端后台、订单流转这一整套,其中最典型也最适合用来理解业务代码怎么写的一个模块,就是管理后台的“新增菜品”。这个功能看起来简单,无非就是填几个字段、传一张图片、存个数据库,但真做起来,里面涉及接口设计、数据库事务、文件上传、前后端联调这些环节,每一步都有讲究。这篇文章就把我当时做“苍穹外卖-新增菜品代码开发”的完整思路和写法拆开讲一遍,从需求梳理到后端实现、再到联调时常见的坑,一次说清楚。
1. 需求拆解与方案设计
1.1 从页面反推功能点
做开发最忌讳上来就写代码。拿到“新增菜品”这个需求,先别急着建工程、建表,第一步应该打开前端的菜品管理页面,把页面上的每一个输入框、每一个按钮都当成一个隐藏需求来看。
苍穹外卖的管理端菜品页面,大致长这样:页面上方有菜品分类的筛选下拉框,中间是菜品列表,右上角有一个“新增菜品”按钮。点进去之后是一个表单页,包含这些信息:
- 菜品名称
- 菜品分类(下拉选择,比如“热菜”“凉菜”“汤类”)
- 菜品价格
- 菜品图片(点击上传,回显缩略图)
- 菜品描述
- 口味配置(可以动态添加多组,比如“辣度:不辣、微辣、中辣、特辣”;“温度:热、温、冰”)
- 是否起售(一个开关)
从这些东西反推后端,我们至少需要提供这些能力:
- 查询分类列表的接口,给下拉框提供数据源。
- 文件上传接口,图片传到服务器后返回可访问的URL。
- “新增菜品”的提交接口,接收基本信息+口味列表,一次性入库。
- 菜品表和口味表必须同时写入,要么都成功,要么都失败,这一步涉及事务。
另外有一个细节容易被忽略——分类下拉框。新增菜品页面的分类数据不是写死的,是由接口动态返回的,也就是说分类表(Category)是独立的,菜品表通过category_id去关联它。我们在设计接口的时候,不要傻乎乎地让前端把分类名称传过来,应该传分类ID,后端只负责存ID,展示时再根据ID去关联查询。
1.2 技术选型与分层理由
苍穹外卖这个项目后端用的是Spring Boot + MyBatis,数据库是MySQL,缓存用Redis,文件存储通常是阿里云OSS,当然本地开发的时候也可以直接存服务器磁盘。
为什么用MyBatis不用MyBatis Plus?苍穹外卖这套代码有个特点,它故意不用MP的BaseMapper,而是手写XML里的SQL。原因很简单:这是一个教学型项目,手写SQL能让你更清楚每条语句在干什么,也方便处理“菜品+口味”这种一对多的批量插入场景。我们做新增菜品的时候,别偷懒去引入MP,手写Mapper反而更可控。
分层方面,标准的三层架构:Controller接收参数、Service处理业务、Mapper操作数据库。再加上DTO、VO、Entity这样一层参数对象,形成完整的调用链。新增菜品这里,接收前端参数用DishDTO,入库用Dish实体,返回给前端展示用DishVO,三者各司其职,不要混着用。
这样设计的核心原因是:Entity不应该直接暴露给前端。实体类里的字段和数据库表一一对应,但前端传过来的参数可能比实体类多(比如口味列表flavors),也可能比实体类少(比如创建时间、更新时间是后端自己填的)。如果直接用Entity接收前端参数,多出来的flavors字段没地方放,还得在实体类里硬塞一个跟表结构无关的属性,这就破坏了一一对应的关系,后面维护起来很别扭。
2. 数据库设计与数据模型分层
2.1 菜品表、口味表怎么建
新增菜品涉及两张表:菜品表dish和菜品口味表dish_flavor。先看表结构:
dish表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| name | varchar(32) | 菜品名称 |
| category_id | bigint | 分类ID |
| price | decimal(10,2) | 价格,单位元 |
| image | varchar(255) | 图片URL |
| description | varchar(255) | 描述信息 |
| status | int | 状态,1起售 0停售 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
| create_user | bigint | 创建人ID |
| update_user | bigint | 更新人ID |
| is_deleted | int | 逻辑删除标记 |
dish_flavor表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| dish_id | bigint | 菜品ID,关联dish表 |
| name | varchar(32) | 口味名称,比如“辣度” |
| value | varchar(255) | 口味值,比如“不辣,微辣,中辣,特辣” |
这里最容易出错的地方在price字段的存储类型。菜品价格有小数,用decimal(10,2)没问题,但是前端传过来的价格一般是一个带小数的浮点数,比如“28.00”,而后端实体类里如果定义成BigDecimal,接收时不会有精度问题。千万不要用double或float存金额,二进制浮点数在计算时会产生精度误差,虽然存个价格误差可能很小,但涉及到金额合计、折扣计算这种场景,误差会累积出来,到时候查账对不上就麻烦了。
dish_flavor表里的value字段,存的是一个逗号分隔的字符串。为什么不用一张更细的口味明细表?因为一个口味项的值就是一组可选项,比如“不辣,微辣,中辣,特辣”是一整个字符串。如果拆成多行,例如每条一个选项,那么每次查询要按顺序组装回来,而且用户修改口味的时候要逐行比对哪些删了哪些加了,操作起来太繁琐。用逗号拼接存储虽然不符合严格的数据库第三范式,但在这种“选项组”场景下反而是最实际的方案——写入简单、读取简单、展示也简单,只要保证value每个选项之间用英文逗号分隔就可以了。
2.2 DTO、Entity、VO的字段划分
新建菜品涉及的三个对象,一定要把字段划分清楚。
Dish实体类,字段和数据库表一一对应,一个不多一个不少。包括id、name、categoryId、price、image、description、status、createTime、updateTime、createUser、updateUser、isDeleted,手动写setter/getter,或者用Lombok的@Data注解都行。
DishDTO是用来接收前端请求参数的,它的字段是前端实际传给后端的内容。通常包含:
- name
- categoryId
- price
- image
- description
- status
- flavors(List<DishFlavor>)
可以看到DTO比实体类多了一个flavors,少了createTime、updateTime这类后端自行填充的字段。这个分流就是DTO存在的意义。
DishVO是返回给前端展示用的,通常会在菜品基础上额外带一个categoryName(分类名称),方便前端列表页直接显示“热菜”“凉菜”而不需要再查一次分类表。在新增菜品的接口中,DishVO用得不多,主要是列表查询时使用,但整个模块的数据模型链条是一致的,所以我建议从一开始就把这三个类分开建好。
2.3 参数校验不能省
新增菜品接口写起来不难,但有一些隐藏校验规则必须在Service层处理,否则会出现脏数据。
- 菜品名称不能为空,而且要检查当前分类下是否已经存在同名菜品。
- 分类ID必须合法,得是数据库里真实存在的分类。
- 价格必须大于0。
- 图片URL不能为空,否则前台菜品列表会出现裂图。
- 口味列表可以为空,但如果传了,name和value都不能为空。
这些字段如果等数据库写入时报错再去处理,故障已经发生了。我习惯在Controller层入口先做一次参数非空校验,然后在Service层做业务规则的二次校验。比如“同名菜品检查”,只能放在Service,因为要查库;而“name是否为空”这种纯参数检查,在前端和后端入口都拦一道,体验最好。
3. 后端核心代码开发与接口实现
3.1 Controller层路由设计
新增菜品的接口路径,一般定义成POST /admin/dish,注意是POST请求,因为这是新增操作。在Controller里,代码大概长这样:
@RestController @RequestMapping("/admin/dish") public class DishController { @Autowired private DishService dishService; @PostMapping public Result<String> save(@RequestBody DishDTO dishDTO) { dishService.saveWithFlavor(dishDTO); return Result.success(); } }这里有几个值得注意的细节:
第一,@RequestBody注解不能漏。前端提交的是JSON格式的数据,后端必须用这个注解把JSON反序列化成DishDTO对象。如果漏了,Spring MVC会尝试从表单参数里绑定数据,结果就是disDTO里所有字段全是null,接口却响应成功——这是最坑的一种报错,因为接口没报错,但数据库里插入了一条空数据的记录。
第二,Controller只做参数接收和结果封装,不要写业务代码。一旦在Controller里写了查库、判断、计算之类的逻辑,项目后期维护的时候,同样的逻辑可能会散落在多个Controller里,改一处漏一处。把所有业务逻辑收敛到Service层,是让代码可维护的底线。
第三,统一的返回结果类Result。苍穹外卖项目里一般会定义一个通用返回对象,包含code、msg、data三个字段。新增成功后返回code=1表示成功。这里别返回裸数据,给前端一个统一结构,前端处理起来会轻松很多。
3.2 Service层:事务与业务逻辑
Service层是新增菜品这个模块的核心。先看代码结构:
@Service public class DishServiceImpl implements DishService { @Autowired private DishMapper dishMapper; @Autowired private DishFlavorMapper dishFlavorMapper; @Override @Transactional public void saveWithFlavor(DishDTO dishDTO) { // 1. 校验菜品名称是否重复 int count = dishMapper.countByNameAndCategory(dishDTO.getName(), dishDTO.getCategoryId()); if (count > 0) { throw new RuntimeException("当前分类下已存在同名菜品"); } // 2. 把DTO转成Dish实体 Dish dish = new Dish(); BeanUtils.copyProperties(dishDTO, dish); dish.setStatus(1); // 默认起售 dish.setCreateTime(LocalDateTime.now()); dish.setUpdateTime(LocalDateTime.now()); dish.setCreateUser(BaseContext.getCurrentId()); dish.setUpdateUser(BaseContext.getCurrentId()); dish.setIsDeleted(0); // 3. 插入菜品主表 dishMapper.insert(dish); // 4. 获取菜品的自增主键 Long dishId = dish.getId(); // 5. 插入口味表 List<DishFlavor> flavors = dishDTO.getFlavors(); if (flavors != null && flavors.size() > 0) { flavors.forEach(flavor -> { flavor.setDishId(dishId); }); dishFlavorMapper.insertBatch(flavors); } } }单独把几个关键点拎出来讲。
@Transactional注解必须加。这个方法做了两次插入操作:先插入dish主表,再插入dish_flavor口味表。如果第二次插入失败,但第一次已经成功了,没有事务的话,数据库里就会留下一个没有口味数据的菜品。加了事务之后,任何一步异常,前面已经执行成功的SQL也会回滚,保证数据一致性。这一步是整个新增菜品模块最重要的知识点了。
BeanUtils.copyProperties的使用注意。Spring框架自带这个工具类,可以把DTO里的同名字段拷贝到实体类,省去手动set那一大堆属性。但它有个容易踩坑的地方:如果字段名对不上,比如DTO里叫categoryId,实体类里叫categoryId,肯定没问题;但如果一个叫category_id,另一个叫categoryId,那拷贝就是静默失败的,不报错,但字段值为null。所以用之前一定确认两边字段名完全一致,最好把代码跑起来看一眼数据库记录。
手动设置创建时间、更新时间和操作人ID。这种字段不应该由前端传,前端也没必要知道是谁在操作。createUser和updateUser是从当前登录用户的上下文里取的,通常通过ThreadLocal实现,也就是BaseContext.getCurrentId()。如果你在自己的项目里没做这套上下文机制,最简单的替代方案是先从Redis里取登录用户的ID,但代码结构上肯定不如ThreadLocal干净。这里顺便说一句,学习苍穹外卖项目的时候,这套BaseContext + ThreadLocal的写法值得重点看,它是解决“当前登录用户是谁”这个问题的经典套路。
设置默认状态status。新增菜品的默认状态一般是起售,也就是1,如果页面上的“是否起售”开关让前端传了,那就以前端传的为准。这里有个小坑:DTO里的status字段如果前端没传,会是null,插入数据库后status是null,可能违反非空约束。所以比较稳妥的做法是在Service层判断,前端传了就设置传的值,没传就默认1。
3.3 Mapper层:主键回填与批量插入
Mapper层有两个核心SQL需要好好写:插入菜品主表时拿到自增主键,批量插入口味表。
插入菜品主表:
<insert id="insert" parameterType="Dish" useGeneratedKeys="true" keyProperty="id"> INSERT INTO dish (name, category_id, price, image, description, status, create_time, update_time, create_user, update_user, is_deleted) VALUES (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser}, #{isDeleted}) </insert>useGeneratedKeys="true"和keyProperty="id"这两行配置,作用是让MyBatis在插入成功后,将数据库自动生成的主键值回填到传入的Dish对象的id属性上。如果不配置这个,插入后dish.getId()返回的是null,后面给口味表设置dishId的时候就会全部变成null,菜品的口味关联就丢了。而且这种错误不会立刻报错,直到你查看菜品详情时发现口味一直是空的,才会追查到这个位置。
批量插入口味表:
<insert id="insertBatch"> INSERT INTO dish_flavor (dish_id, name, value) VALUES <foreach collection="list" item="flavor" separator=","> (#{flavor.dishId}, #{flavor.name}, #{flavor.value}) </foreach> </insert>foreach标签是MyBatis里批量插入的标配。这里注意collection="list",对应Mapper接口里参数名是List<DishFlavor>,如果你没加@Param("list")注解,默认就是list。批量插入的好处是只执行一条SQL,而不是循环逐条insert,性能上差别在数据量大的时候非常明显。虽然一次新增菜品的口味通常只有几个,但养成批量操作的习惯没有坏处。
3.4 文件上传接口:本地存储与OSS两种方案
新增菜品必须上传图片,所以文件上传接口也是这个模块的一环。开发调试阶段,我建议先做本地存储,发布到服务器再切OSS。
本地存储方案:
@RestController @RequestMapping("/admin/common") public class CommonController { @PostMapping("/upload") public Result<String> upload(MultipartFile file) { String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString() + suffix; String basePath = "/usr/local/img/"; File dir = new File(basePath); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(basePath + fileName)); return Result.success("http://localhost:8080/img/" + fileName); } catch (IOException e) { throw new RuntimeException("文件上传失败"); } } }两个关键点:文件重命名和虚拟路径映射。
文件重命名一定要做。如果直接用原始文件名,比如“糖醋排骨.jpg”,两个不同商家上传了同名图片,后传的就把先传的覆盖了。用UUID重命名后每次都不一样,基本不可能冲突,而且保留了原始文件的后缀名,浏览器识别图片格式没问题。
虚拟路径映射:本地存储的图片放在磁盘某个目录,但前端要能通过URL访问这张图片。Spring Boot里需要配置静态资源映射:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/img/**") .addResourceMapping("/img/") .addResourceLocations("file:/usr/local/img/"); } }这样访问http://localhost:8080/img/xxx.jpg时,Spring会把请求转发到服务器的/usr/local/img/目录下找文件。
如果部署到云环境,一般用OSS存储。思路是后端生成一个OSS的签名URL或者直接用AccessKey上传,然后返回URL给前端。相比本地存储,OSS的好处是图片不占服务器磁盘、自带CDN加速、不用考虑备份。缺点是要引入SDK,配置多一点。
这里给一个选择建议:学习阶段用本地存储,一来不需要开通云服务,二来出了问题可以在本地直接看磁盘文件,排查方便。等要上线了再改成OSS,两张方案的接口对外完全一致——都是返回一个图片URL字符串,所以前端不用改。
4. 前端页面联调与参数细节
4.1 前端表单提交的数据结构
苍穹外卖的管理端前端是Vue项目(也有H5版本),新增菜品页面的表单提交结构大致是这样:
{ "name": "糖醋排骨", "categoryId": 8, "price": 36.00, "image": "http://localhost:8080/img/uuid.jpg", "description": "酸甜口,外酥里嫩", "status": 1, "flavors": [ {"name": "辣度", "value": "不辣,微辣,中辣,特辣"}, {"name": "温度", "value": "热,温"} ] }联调的时候,重点检查两个地方:
第一,字段名大小写和后端DTO属性是否一致。比如前端传categoryId,后端DTO属性也是categoryId,一致就能正确绑定。如果前端传了下划线风格category_id,后端DTO没有配置@JsonProperty,那绑定就是null。
第二,price字段类型。前端表单输入的价格是字符串"36.00",JSON序列化后是数字36.00,后端用BigDecimal接收没问题。但如果前端把价格当字符串传"36.00",BigDecimal也能正常转换,问题不大。真正要命的是前端传了null或者空字符串,这时后端BigDecimal转换会直接报类型不匹配的400错误。所以在后端参数校验时,price的非空判断要前置。
4.2 接口联调时怎么排查问题
前后端联调阶段,最常见的现象是:前端点击“保存”按钮,提示成功,但数据库里没数据;或者接口报500错误。按照我的经验,排查顺序应该是这样的:
打开浏览器开发者工具(F12)的Network面板,看“新增菜品”请求的状态码。如果是404,说明路径没对上;如果是405,说明方法不对,比如前端POST、后端GET;如果是400,大概率是参数类型不匹配或者字段名对不上。
看请求Payload里的JSON结构,和后端DTO类的字段逐个对照。这是眼力活,但比猜快得多。前端多一个字段不影响,少一个字段要看后端有没有做非空校验。
看后端控制台日志。如果接口到了Controller,日志里会有请求路径和方法;如果连日志都没有,说明请求根本没进Spring容器,可能是拦截器拦截了,比如登录校验失败。苍穹外卖项目里有JWT拦截器,如果你带着前端传的token访问,token过期了就会被拦截器拦下来,返回401,前端页面可能提示登录过期,但有时候前端并没有正确处理这个状态,而是弹出“请求失败”,这时候要优先检查token。
确认数据库里有没有数据。接口返回成功,但数据库里没有——最常见的原因是事务回滚了但异常被吞掉了。有时候Service层代码try-catch里把异常catch住,然后返回了一个正常结果,事务感知不到异常就不会回滚数据,但SQL实际执行是失败的。这种情况要从日志里找SQL执行异常的堆栈。
4.3 开发阶段的一个好消息
新增菜品联调成功后,会立刻在菜品列表和点餐页面体现效果,这对学习来说是极具正反馈的一步。我建议把“新增菜品”和“菜品分页查询”“菜品起售停售”几个接口放在同一天完成,串起来测一遍完整流程:
- 新增一个带口味的菜品
- 到分页列表查询,确认数据能查到,图片能显示
- 在用户端小程序里刷新,确认这个菜出现在对应分类下
- 点“停售”,去用户端刷新,确认菜品下架
这一套走通了,你对整个项目的前后端数据流转会突然通透很多。
5. 高频报错与避坑清单
5.1 我自己踩过的坑,整理成了表
| 问题 | 现象 | 根因 | 解决办法 |
|---|---|---|---|
| 新增后口味数据为空 | 菜品保存成功,但查看详情没有口味 | 插入主表后没有回填主键 | XML里加useGeneratedKeys和keyProperty |
| 前端报400错误 | 请求失败,后端日志提示类型不匹配 | 字段名不一致或参数类型不对 | 对照DTO字段和请求JSON逐字检查 |
| 上传图片后前端显示裂图 | 图片访问返回404 | 静态资源映射没配置,或文件路径错误 | 检查WebMvcConfig的addResourceHandlers |
| 数据库里字段是null | 菜品成功插入但name/price为null | 前端传参被拦截,或DTO没接收 | 检查Controller的@RequestBody注解 |
| 新增同名菜品没拦截住 | 数据库里出现两条同名记录 | 校验逻辑没加事务,或并发下重复提交 | Service层增加名称唯一性校验,必要时加唯一索引 |
| 中文乱码 | 名称在数据库里变成问号 | MySQL连接串没配置utf8 | JDBC连接串增加characterEncoding=utf8 |
第二行那个字段名不一致的坑我印象很深。我当时做的时候,前端传的字段名是category_id,下划线风格,后端的DTO属性是categoryId,驼峰风格,结果categoryId一直是null。后来一查是前端代码里写死了字段名,改前端还是改后端都可以,但关键是两边必须对齐。这里特别注意:MyBatis的驼峰映射配置mapUnderscoreToCamelCase只影响数据库列名和Java实体类属性的映射,不影响前端JSON字段和后端DTO属性之间的绑定,后者靠的是Jackson的字段名匹配。
5.2 关于事务的深度提醒
@Transactional这个注解,很多人只停留在“加上就能回滚”的认知上,实际使用时有几个细节:
默认情况下,只有RuntimeException和Error会触发回滚,受检异常(Exception的子类,比如IOException)不会触发回滚。如果你在Service里catch住了异常并重新抛出一个受检异常,事务不会回滚。想让它回滚,要用@Transactional(rollbackFor = Exception.class)。
同一个类内部调用,事务会失效。比如SaveWithFlavor方法在自己类的另一个方法a里面被调用,a没有加事务,这是B方法加不加都不会生效。因为Spring的事务是靠AOP代理实现的,内部调用不会经过代理。把事务方法写在独立的Service类里调用,才能保证事务生效。
事务方法里不要try-catch吞掉异常然后返回成功,否则事务判断不了异常,该回滚的不回滚。
5.3 操作经验总结
新增菜品这个模块做完,我对整个苍穹外卖项目的理解会深很多。这里把实操中的几条经验整理一下,新手照着做能少走弯路:
先建表再写代码。不要先写Mapper再回去建表,顺序反了容易因为字段名对不上排查半天。表结构确认无误后,再写实体类,字段一一对应。
日志打印要到位。在Service层的每个关键节点打日志,比如“开始新增菜品”“插入菜品成功,主键ID=xxx”“插入口味成功”。没有日志的话,出了问题只能靠猜,效率低得让人想撞墙。
接口自测用Swagger或Postman。苍穹外卖项目一般集成了Knife4j接口文档,开发完Controller和Service后,先在接口文档里直接调一遍,确认数据能正确入库,再通知前端联调。连自测都没过就去联调,浪费的是双方的时间。
善用BaseContext做审计字段。创建人、更新人这种字段不要在前端传,后端从登录上下文统一填充。这不仅是规范问题,还是安全问题,不然任何人都能伪造操作人。
口味列表允许为空但要考虑兜底。有些菜品确实没有口味,比如“米饭”。这种情况flavors传一个空数组即可,前端最好也别传null。后端要同时兼容null和空数组,别因为在forEach里遍历null而报空指针。
结尾
做苍穹外卖的过程中,“新增菜品”让我对事务、主键回填、分层结构和前后端联调都有了更实际的理解。尤其是事务这一块,之前看理论总觉得“加了@Transactional就完事了”,直到自己遇到一次口味没有插入成功、菜品却保存了的情况,才明白注解背后的代理机制有多重要。如果你也在做这个项目,建议别只照着教程复制代码,试着把每个字段为什么这么设计、每个注解为什么这么加想明白,动手走一遍接口自测,再手动往数据库里插几条脏数据看系统怎么应对。踩坑本身才是学习最有效率的方式。