
苍穹外卖这个项目不少学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表字段类型说明idbigint主键自增namevarchar(32)菜品名称category_idbigint分类IDpricedecimal(10,2)价格单位元imagevarchar(255)图片URLdescriptionvarchar(255)描述信息statusint状态1起售 0停售create_timedatetime创建时间update_timedatetime更新时间create_userbigint创建人IDupdate_userbigint更新人IDis_deletedint逻辑删除标记dish_flavor表字段类型说明idbigint主键自增dish_idbigint菜品ID关联dish表namevarchar(32)口味名称比如“辣度”valuevarchar(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是用来接收前端请求参数的它的字段是前端实际传给后端的内容。通常包含namecategoryIdpriceimagedescriptionstatusflavorsListDishFlavor可以看到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 ResultString 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三个字段。新增成功后返回code1表示成功。这里别返回裸数据给前端一个统一结构前端处理起来会轻松很多。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. 插入口味表 ListDishFlavor 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 idinsert parameterTypeDish useGeneratedKeystrue keyPropertyid 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}) /insertuseGeneratedKeystrue和keyPropertyid这两行配置作用是让MyBatis在插入成功后将数据库自动生成的主键值回填到传入的Dish对象的id属性上。如果不配置这个插入后dish.getId()返回的是null后面给口味表设置dishId的时候就会全部变成null菜品的口味关联就丢了。而且这种错误不会立刻报错直到你查看菜品详情时发现口味一直是空的才会追查到这个位置。批量插入口味表insert idinsertBatch INSERT INTO dish_flavor (dish_id, name, value) VALUES foreach collectionlist itemflavor separator, (#{flavor.dishId}, #{flavor.name}, #{flavor.value}) /foreach /insertforeach标签是MyBatis里批量插入的标配。这里注意collectionlist对应Mapper接口里参数名是ListDishFlavor如果你没加Param(list)注解默认就是list。批量插入的好处是只执行一条SQL而不是循环逐条insert性能上差别在数据量大的时候非常明显。虽然一次新增菜品的口味通常只有几个但养成批量操作的习惯没有坏处。3.4 文件上传接口本地存储与OSS两种方案新增菜品必须上传图片所以文件上传接口也是这个模块的一环。开发调试阶段我建议先做本地存储发布到服务器再切OSS。本地存储方案RestController RequestMapping(/admin/common) public class CommonController { PostMapping(/upload) public ResultString 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.00JSON序列化后是数字36.00后端用BigDecimal接收没问题。但如果前端把价格当字符串传36.00BigDecimal也能正常转换问题不大。真正要命的是前端传了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连接串没配置utf8JDBC连接串增加characterEncodingutf8第二行那个字段名不一致的坑我印象很深。我当时做的时候前端传的字段名是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层的每个关键节点打日志比如“开始新增菜品”“插入菜品成功主键IDxxx”“插入口味成功”。没有日志的话出了问题只能靠猜效率低得让人想撞墙。接口自测用Swagger或Postman。苍穹外卖项目一般集成了Knife4j接口文档开发完Controller和Service后先在接口文档里直接调一遍确认数据能正确入库再通知前端联调。连自测都没过就去联调浪费的是双方的时间。善用BaseContext做审计字段。创建人、更新人这种字段不要在前端传后端从登录上下文统一填充。这不仅是规范问题还是安全问题不然任何人都能伪造操作人。口味列表允许为空但要考虑兜底。有些菜品确实没有口味比如“米饭”。这种情况flavors传一个空数组即可前端最好也别传null。后端要同时兼容null和空数组别因为在forEach里遍历null而报空指针。结尾做苍穹外卖的过程中“新增菜品”让我对事务、主键回填、分层结构和前后端联调都有了更实际的理解。尤其是事务这一块之前看理论总觉得“加了Transactional就完事了”直到自己遇到一次口味没有插入成功、菜品却保存了的情况才明白注解背后的代理机制有多重要。如果你也在做这个项目建议别只照着教程复制代码试着把每个字段为什么这么设计、每个注解为什么这么加想明白动手走一遍接口自测再手动往数据库里插几条脏数据看系统怎么应对。踩坑本身才是学习最有效率的方式。