苍穹外卖Day3:菜品管理与图片上传实战解析

发布时间:2026/10/22 3:29:57
苍穹外卖Day3:菜品管理与图片上传实战解析 《苍穹外卖》这个项目走到 Day3才是真正开始“做业务”的一天。前两天的员工登录、分类管理更多是在搭骨架数据库表建好了、接口规范定下来了、前后端联调路子跑通了到 Day3 做菜品管理的时候才会发现单表 CRUD 和真实业务差距有多大。如果你正卡在 Day3或者准备从头刷这个项目今天这篇就把菜品管理从设计到落地、再到踩坑排查完整捋一遍。Day3 的核心是“菜品管理 图片上传”听起来就是增删改查但实操起来要处理文件传输、多表插入、数据回显、状态联动再叠加后续缓存更新复杂度一下子就不一样了。这一天的接口数量不算多但每一块的细节密度很高很多人都是在这里第一次感受到“原来真实项目不是写几个接口就完事”。1. 项目背景与 Day3 的定位思考1.1 Day3 在整个外卖项目里处在什么位置苍穹外卖这种学习项目前面的章节基本是在建地基。员工登录解决的是“谁能进后台”的问题分类管理解决的是“菜品怎么归类”的问题到了 Day3才轮到真正面向用户的核心数据菜品。为什么菜品重要因为外卖系统几乎所有后续业务都围绕它展开。套餐会引用菜品购物车会加入菜品订单会锁定菜品的名称、图片、价格甚至后端的销量统计、推荐系统、门店管理也会以菜品数据为基础。可以说菜品表是外卖项目的“数据枢纽”。所以 Day3 设计的菜品表结构、口味表结构、状态字段、图片存储方式会影响后面好几个模块的实现方式。前端会有大量页面去展示菜品图片和价格后端会有大量接口去查询菜品信息。如果这里设计得不好后面做套餐模块和购物车模块的时候来回改表结构是很痛苦的。我当初刷到这一天的第一反应是这不就是一个普通的管理后台 CRUD 吗真正动手之后才发现单表 CRUD 只是起步文件上传、多表事务、状态变更联动每一个都是独立的知识点而且都是以后开发工作中高频遇到的东西。1.2 这一天的功能清单与核心目标按照最常见的版本Day3 的功能可以拆成这么几块菜品新增后台管理员录入菜品名称、价格、分类、图片、描述同时可以选择多个口味比如“甜度无糖/半糖/全糖”。菜品分页查询按菜品名称模糊筛选按分类筛选分页展示菜品列表。菜品修改与回显点击编辑后表单里要回填原有数据包括已有的口味标签修改后保存。菜品起售与停售列表页提供状态开关停售后的菜品前端不应该展示。图片上传新增菜品时选择本地图片上传到服务器返回图片访问地址。这里面最难的不是 SQL 的增删改查而是几个跨领域的协作点前端用 FormData 上传图片和处理菜品数据后端用 MultipartFile 接收文件、用 DTO 接收业务数据。口味是独立子表新增菜品时要同时写主表和子表修改菜品时要先删子表再重新插入。起售/停售不是简单改一个字段还关系到缓存里的菜品数据是否同步。把所有环节串起来你会形成一张完整的业务时序图。个人经验是与其急着写代码不如先把接口文档里这五块功能对应到数据库表和前端页面上搞清楚每一块的数据流再动手会顺利很多。2. 核心技术点解析DTO 设计、文件上传与多表事务2.1 为什么不能直接用实体类接收前端参数很多人第一次做新增菜品会习惯性地在 Controller 方法参数里写实体类 Dish然后直接用 dish 去插入数据库。这么做在第1天、第2天可能没毛病因为员工表和分类表字段简单。但到了菜品这里情况变了。前端的菜品表单除了菜品本身的信息还会提交一个 flavors 数组里面是多个口味项比如{ name: 杨枝甘露, categoryId: 10, price: 18.00, image: https://xxx/abc.jpg, description: 经典港式甜品, status: 1, flavors: [ { name: 甜度, value: [无糖, 半糖, 全糖] }, { name: 温度, value: [热饮, 常温, 冰饮] } ] }而 Dish 实体里压根没有 flavors 这个字段口味数据是要分开写到 dish_flavor 表里的。如果你用 Dish 直接接收flavors 就丢了。所以必须单独定义一个 DishDTO把菜品基础字段和口味列表都装进去Data public class DishDTO implements Serializable { private Long id; private String name; private Long categoryId; private BigDecimal price; private String image; private String description; private Integer status; private ListDishFlavor flavors; }这个 DTO 就是前后端数据交互的“协议格式”。后端接收到 DishDTO 后再把它拆成 Dish 对象插入主表拆出口味列表插入子表。传参这块还有个容易踩的点前端如果直接把 JS 对象用 axios post 给后端JSON 里的 flavors 是数组Spring 默认可以反序列化成 List。但有些前端同学会把 flavors 转成 JSON 字符串再塞进表单后端用 List 接收就会报类型不匹配。所以后端 DTO 里直接写 List 类型是合理的同时要求前端别做多余序列化。2.2 图片上传的本质文件流、存储路径与访问 URL图片上传对新手来说是个容易产生心理障碍的功能其实拆开看就三件事接收前端传来的 MultipartFile。把文件保存到某个存储位置。返回一个能访问到该文件的 URL。本地磁盘存储是最容易理解的方式。后端把上传的文件写到应用所在服务器的某个目录比如/usr/local/upload/然后把文件名处理后拼出一个 URL 返回给前端。但这里有个关键文件保存路径必须是可被 HTTP 访问的。Spring Boot 默认只把 static、public 这类静态资源目录作为静态资源根路径你随机写一个磁盘路径浏览器不一定能访问到。常见的做法是在配置类里加一个资源映射Configuration public class WebMvcConfiguration extends WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadPath); } }文件名的处理也需要留意。直接用用户上传的原始文件名有两个问题一是可能包含中文和特殊字符二是多人上传同名文件会互相覆盖。所以正规做法是生成 UUID 作为新文件名再拼接原始文件的后缀String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName UUID.randomUUID().toString() suffix;后面做云存储的时候思路也是一样的只是把“保存到本地目录”换成“调用对象存储服务 SDK 上传”然后直接返回云端 URL。本地存储和云存储的差别主要在部署环境本地存储在后端多机部署时文件分散在各台机器上访问会有问题云存储则天然适合分布式部署。学习阶段用本地存储没问题理解透原理后扩展成云存储就是换一段实现的事。2.3 菜品主表与口味子表的多表事务控制新增菜品时要往 dish 表插一条记录再往 dish_flavor 表插多条记录。这两个操作必须保证“要么都成功要么都失败”。想象一下如果主表插入成功了口味插入失败前端看到的提示是报错但数据库里已经多了一条没有口味的菜品下次查询时这条残缺数据就很麻烦。解决办法就是事务注解Transactional public void saveWithFlavor(DishDTO dishDTO) { // 1. 插入 dish 主表获取自增 id // 2. 遍历口味列表插入 dish_flavor 子表每一条都带上 dishId }课程例子可能只用了Transactional但对于想深入理解的人来说我建议顺手搞清楚两个知识点Spring 事务默认只在 RuntimeException非检查异常时回滚受检异常默认不回滚。如果你的业务代码把异常 catch 掉后没有重新抛出事务也不会回滚。Transactional加在 private 方法上是无效的同类内部调用也无效。只有通过 Spring 代理调用的 public 方法才生效。另外还有一个实际开发里的通用套路在插入主表后拿到自增主键 id。如果用的是 MyBatis需要在映射文件里开启useGeneratedKeysinsert idinsert parameterTypeDish useGeneratedKeystrue keyPropertyid insert into dish (name, category_id, price, image, description, status, create_time, update_time, create_user, update_user) values (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser}) /insert这样插入完 Dish 对象后它的 id 属性会被数据库自增主键回填后面写口味表时才能设置正确的 dishId。这个细节没有处理好口味数据就关联不上菜品。3. 实操过程与核心环节实现3.1 新增菜品接口的完整实现逻辑新增菜品我建议按“参数校验 → 组装数据 → 插入主表 → 插入子表”四步来做。Controller 层只负责接收参数和返回结果业务逻辑放到 Service 层事务边界用Transactional控制。Controller 示例PostMapping(/admin/dish) ApiOperation(新增菜品) public ResultString save(RequestBody DishDTO dishDTO) { dishService.saveWithFlavor(dishDTO); return Result.success(); }Service 实现的核心逻辑Transactional public void saveWithFlavor(DishDTO dishDTO) { Dish dish new Dish(); BeanUtils.copyProperties(dishDTO, dish); // 补充分类不存在的异常校验 Category category categoryMapper.getById(dishDTO.getCategoryId()); if (category null) { throw new BusinessException(分类不存在); } dish.setStatus(1); // 新增默认起售 dish.setCreateTime(LocalDateTime.now()); dish.setUpdateTime(LocalDateTime.now()); dishMapper.insert(dish); Long dishId dish.getId(); ListDishFlavor flavors dishDTO.getFlavors(); if (flavors ! null !flavors.isEmpty()) { flavors.forEach(flavor - { flavor.setDishId(dishId); dishFlavorMapper.insert(flavor); }); } }这里面有两个容易被忽略的细节。第一个是 BeanUtils.copyProperties 的坑。DishDTO 和 Dish 字段名大部分一致但 DTO 里没有 createTime、updateTime、createUser、updateUser 这些字段所以要手动补。此外如果两个类都有 id拷贝时会把 id 也拷过去新增菜品时如果前端传了个不会存在的 id会有隐患。稳妥做法是手动字段赋值或者先拷 DTO 到 Dish再手动把 id 置空。第二个是口味列表为空的情况。有些菜品就是没有口味比如“白米饭”前端不传 flavors后端不要强行遍历。先判空再遍历否则空指针异常直接从 Service 冒到全局异常处理器接口报 500。3.2 菜品分页查询与条件筛选的实现思路分页查询是管理后台最常见的功能形态Day3 的菜品分页查询一般支持两个筛选条件按菜品名称模糊查询、按分类精确查询。页面顶部有搜索框和分类下拉框底部有分页条。参数设计为 page、pageSize、name、categoryId其中 name 是可选模糊查询categoryId 是可选精确查询。Mapper 里用动态 SQL 拼条件select idpageQuery resultTypeDishVO select d.*, c.name as categoryName from dish d left join category c on d.category_id c.id where if testname ! null and name ! and d.name like concat(%, #{name}, %) /if if testcategoryId ! null and d.category_id #{categoryId} /if /where order by d.create_time desc /select注意 left join 分类表而不是 inner join。为什么如果某个分类被删除或停用菜品数据仍然要能查出来。left join 可以保证菜品记录不丢失分类名称查不到时就显示 null前端可以展示“已删除分类”之类的兜底文案。分页插件用 PageHelper 的话代码相当简单public PageResult pageQuery(DishPageQueryDTO queryDTO) { PageHelper.startPage(queryDTO.getPage(), queryDTO.getPageSize()); ListDishVO list dishMapper.pageQuery(queryDTO); PageDishVO page (PageDishVO) list; return new PageResult(page.getTotal(), page.getRecords()); }这里要记住PageHelper.startPage 之后紧跟的第一次查询才生效。如果你在中间多执行了一条无关的 SQL分页就会被污染。这是一个很典型的坑排查的时候特别容易懵。实际开发中还涉及一个图片 URL 补全问题。如果图片保存的是相对路径返回给前端时要补上域名和上下文路径。简单点可以在查询后遍历列表拼接list.forEach(item - { item.setImage(fullImageUrl(item.getImage())); });补全操作放在哪里取决于项目的约定。有些项目在存储时就直接存全量 URL有些则存相对路径。Day3 阶段建议存储完整可访问的 URL前端展示时直接img渲染少一步拼接减少出错点。3.3 菜品修改与数据回显的实现细节菜品编辑页的流程是点击编辑按钮 → 弹出回显窗口 → 修改表单 → 点击保存。这里涉及两个接口一个是“根据 id 查询菜品详情”另一个是“修改菜品”。查询详情时除了菜品主表数据还要查出口味列表public DishVO getByIdWithFlavor(Long id) { Dish dish dishMapper.getById(id); ListDishFlavor flavors dishFlavorMapper.getByDishId(id); DishVO dishVO new DishVO(); BeanUtils.copyProperties(dish, dishVO); dishVO.setFlavors(flavors); return dishVO; }修改菜品时如果口味列表变了不能直接在原来的口味记录上逐条 update因为口味数量可能变多也可能变少。最简单的做法是先根据 dishId 删除所有口味记录再把新的口味列表重新插入。Transactional public void updateWithFlavor(DishDTO dishDTO) { Dish dish new Dish(); BeanUtils.copyProperties(dishDTO, dish); dish.setUpdateTime(LocalDateTime.now()); dishMapper.update(dish); dishFlavorMapper.deleteByDishId(dishDTO.getId()); ListDishFlavor flavors dishDTO.getFlavors(); if (flavors ! null !flavors.isEmpty()) { flavors.forEach(flavor - { flavor.setDishId(dishDTO.getId()); dishFlavorMapper.insert(flavor); }); } }这里有个很典型的隐患deleteByDishId 的 SQL 一定要带上 dish_id 条件别写成delete from dish_flavor。真有人写错过一次修改把整张表清空。这种事故在真实业务中就是生产故障级别了所以这种删除语句写完之后建议先看一眼 where 条件。3.4 起售停售与数据同步起售停售听起来只是改一个 status 字段但它的影响面比想象中大。菜品停售后前端小程序或 App 端就不应该再展示这道菜。如果用 Redis 缓存了菜品列表改完数据库后必须同步处理缓存否则前端还会继续看到已经停售的菜。Day3 如果还没引入 Redis可以先用数据库状态字段实现基础逻辑代码很简单public void startOrStop(Integer status, Long id) { Dish dish Dish.builder() .id(id) .status(status) .updateTime(LocalDateTime.now()) .build(); dishMapper.update(dish); }这里有一点要提醒如果用 update 字段的 SQL 动态更新Mapper 里要写成set动态 set 只更新非空字段。如果把整条 Dish 对象覆盖式更新status 以外的字段可能会被 null 覆盖。所以“部分字段更新”和“整行更新”要分两个方法或者用动态 SQL 控制。状态变更的联动规则也让很多初学者为难起售一道菜如果它所属的分类已经停售要不要提示如果一个菜品被某个套餐引用停售菜品时是不是要顺带停掉套餐Day3 阶段可以先不考虑套餐联动等做到套餐模块再回头补但代码结构上最好预留一个扩展点比如在 update 之后留一个可扩展的事件通知逻辑。这样后面的功能不需要大改现有方法。在使用 Redis 的项目里起售停售之后还要清理对应的菜品缓存。我见过不少人为了省事直接删除整个菜品缓存 key其实这就是最常见的“Cache Aside Pattern”先更新数据库再删除缓存。简单可靠不用去维护复杂的缓存同步逻辑。后面做到套餐模块时如果套餐信息也缓存了停售菜品还要联想到这里属于后话但提前留出思路会有帮助。4. 常见问题与排查技巧实录4.1 图片上传成功但浏览器访问 404这是 Day3 反馈率最高的问题。代码看起来没问题文件确实保存到了本地目录上传接口也返回了一个 URL但把 URL 复制到浏览器里打开就 404。排查顺序我建议这样走看返回的 URL 路径是否映射到了静态资源处理器。如果访问路径是/upload/xxx.jpg那么配置的 addResourceHandler 必须匹配/upload/**。看文件保存的绝对路径是否和资源映射里的file:路径一致。Linux 下路径末尾要带/Windows 下要注意盘符和斜杠方向。看 application.yml 里是否配置了 upload-path 的自定义属性。很多项目把上传目录放到配置文件里环境不同路径不同。一个非常隐蔽的问题是本地开发用相对路径./upload/但启动工作目录不同文件实际保存的位置也不同。建议把上传路径配置成项目工作目录下的绝对路径并加一段启动日志打印方便第一时间看到实际落盘目录。4.2 新增菜品后前端列表看不到数据如果新增接口返回成功但列表页查不到新记录先别急着看 SQL先打开浏览器 Network 看两个请求新增菜品提交时categoryId 是否为空。分页查询请求是否带了被隐藏的条件比如状态筛选或套餐方向的过滤。前端下拉框的 value 类型也容易出错。分类下拉框的 value 是字符串 10 还是数字 10传给后端经过 JSON 反序列化后可能变成 String。如果你的 Mapper 里 categoryId 是 Long 类型MyBatis 有时候能自动转换有时候会因为类型不匹配查不到数据。最简单的做法是在前端把 value 类型统一成 number后端 DTO 也用 Long。如果分类没问题再看菜品名称。分页查询的模糊搜索用 like但 name 参数可能因为前后端编码不一致导致中文乱码尤其是 GET 请求没设置 Content-Type 时。Spring Boot 默认 UTF-8但前端如果对参数做了 encodeURIComponent 两次后端拿到的仍然是乱码。4.3 事务不生效口味数据丢了一半我已经不止一次看到有人遇到过修改菜品时主表更新成功口味表却一条都没插进去或者新增菜品时报错后主表仍然有数据。事务失效的常见原因主要集中在这几种Transactional加在了 private 方法上Spring 的 AOP 无法代理。同一个类内部调用带事务的方法代理没经过事务失效。方法内部 catch 了异常没有抛出事务感知不到失败。数据库表本身不支持事务比如 MyISAM 引擎不过现在 MySQL 默认 InnoDB不太常见。排查时可以在方法入口打日志确认请求确实进入了 Service 方法开启事务日志查看实际执行过程或者干脆在 catch 块里手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()强制回滚。这里我多说一句事务不是越久越好。一个 Service 方法只做单一业务操作不把外部接口调用、文件写入、消息发送塞进同一个事务里避免事务持有数据库连接过长时间。4.4 编辑回显时口味列表不显示编辑菜品时弹窗里口味标签是空的多半是查询详情的接口没有返回 flavors 字段。检查点有两个DishVO 里有没有 flavors 属性。没有这个字段就算查询了也装不进去。查询口味的 Mapper 有没有按 dishId 过滤。如果 SQL 漏了 where会返回所有菜品口味前端过滤时匹配不到当前菜品也会显示空白。另外前端编辑时如果原始口味 value 是一个数组比如 [无糖,半糖,全糖]后端存储时一般转成 JSON 字符串回显时要解析回数组。这里容易因为转义问题导致解析失败。建议后端统一用 JSON 序列化工具处理前端用 JSON.parse 接收不要在中间环节手工拼接字符串。5. 缓存优化与后续衔接的个人建议5.1 菜品数据为什么适合放进缓存菜品数据有一个明显特征读多写少。用户打开小程序首页会同时加载分类和菜品列表同一道菜可能被几百个用户同时查看。每次请求都去查数据库数据库压力会随着用户量上升快速增加。预热一下思路菜品列表通常按分类维度组织比如“热销榜”“主食类”“小吃类”。这类数据可以以分类 id 为维度存 Redis用户点开某个分类时直接读缓存命中后秒开。Redis 里存什么格式也很重要一般不存对象的 Java 序列化而是转为 JSON 字符串这样跨语言、跨端都能用。Day3 的接口做完后其实已经具备引入缓存的完整业务场景。如果教程把 Redis 放在后面你可以直接在控制器上方加一个简单缓存策略或者手动在 Service 里先查缓存再查数据库提前感受一下。5.2 缓存更新的标准姿势先更新库再删缓存很多人第一次做缓存会想着“更新数据库后再更新缓存”但这会引入脏数据风险。如果更新数据库成功、更新缓存失败数据库和缓存就不一致了。所以业界最通用的做法是先更新数据库然后直接删除缓存等下一次查询时再回填缓存也就是 Cache Aside Pattern。删除缓存时有一个细节删除的 key 要按维度删干净。菜品的列表缓存可能有多个 key比如按分类缓存、按名称搜索缓存、全部菜品缓存。如果只删了其中一种 key其他 key 下的旧数据还是会让用户看到已停售菜品。到了后面做套餐模块这个思路再扩展一步更新菜品时需要同时淘汰“包含该菜品的套餐缓存”。这已经属于缓存联动的范畴了Day3 可以先不用实现但要心里有数。5.3 给后续模块留好的接口边界Day3 做完菜品表里每条数据都有 status 字段、image 字段、flavors 关联。这些字段都不是随便设计的后续套餐模块会用 dishId 去关联菜品购物车模块会展示菜品的图片和价格订单模块会锁定下单时菜品的快照信息。经验之谈是Day3 阶段不要只满足于“能跑通”把 DishVO 和 DishDTO 的字段语义理清楚尤其是哪些字段是给前端展示用的、哪些字段是给订单锁存用的。后面做购物车和订单时你会频繁地复用菜品的图片、名称和价格。如果价格字段从一开始就设计成 BigDecimal 而不是 Double后面金额计算就不会有精度问题。另外公共字段的填充值得单独提一下createTime、updateTime、createUser、updateUser 这类字段如果每个 Service 方法都手动 set代码会很冗余而且容易漏。后面学习中如果学到公共字段自动填充可以回过头想想 Day3 的菜品新增方法体积是不是因此瘦身了不少。动手的时候建议像我一样在本地配一个接口测试环境把 Day3 的五块功能全部走一遍把请求参数、请求头、返回结果记录下来形成一个自己的接口速查表。这个项目的核心不是把代码克隆下来能跑而是亲手把每一个接口从参数到响应都搞明白。Day3 这一章说难不难说简单也不简单。它的价值在于第一次把“文件上传”“多表写入”“条件分页”“状态联动”串在一起。做完之后你会发现后面很多模块的开发流程都很熟悉——无非是把这些基础能力按照新业务再组合一遍。如果你现在正学到这个地方代码可以不用一次写完美但只要把接口调通了、数据能正确落库和回显、页面上能用就已经超过了很多人。剩下的优化等做到缓存和套餐模块时再回头看感受会更不一样。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询