
前两天还在跟登录接口和员工管理打交道的时候我其实没意识到苍穹外卖这个项目真正好玩的地方在哪儿——直到day03进入菜品管理我才找到那种“外卖店终于开火”的感觉。管理端从“能登录”走到“能上菜”中间差的正好就是今天这一课公共字段自动填充、图片上传、新增菜品、分页查询、启售停售和删除。这篇文章就是我的day03学习记录把每个接口背后为什么要这么写、踩过的坑、以及最容易被忽略的业务规则都摊开来说适合正在跟苍穹外卖项目、或者学Spring Boot后台管理开发想找一份完整参考的朋友。1. day03的任务拆解从“能登录”到“能上菜”1.1 菜品模块在苍穹外卖整体业务里的位置苍穹外卖是个典型的外卖业务系统用户端负责点餐、下单、支付管理端负责店铺运营。管理端的数据链路是这样的员工登录后先维护分类热菜、凉菜、套餐分类再往分类里挂菜品鱼香肉丝、拍黄瓜菜品下面还能挂口味辣度、规格。再往上还有套餐和订单。所以菜品是整条业务链上承分类、下启套餐和订单的中间节点。day03之前我做完了员工的增删改查和分类管理day03就是要把菜品这张主表真正盘活。1.2 day03要完成的五件事我把今天的目标列成了一张表方便大家对照自己的项目进度学习模块涉及接口/功能涉及技术点公共字段自动填充新增/修改菜品时自动写入创建人、创建时间等ThreadLocal、MyBatis-Plus MetaObjectHandler图片上传上传菜品图片拿到可访问的URLMultipartFile、本地存储、静态资源映射新增菜品新增菜品基本信息口味列表DTO接收、两表插入、事务分页查询按条件分页查询菜品列表PageHelper、LEFT JOIN、VO转换修改与状态管理回显、修改、启售停售、删除回显查询、先删后插、关联校验为什么菜品管理值得单独花一天因为它是第一个真正涉及“多表关联文件上传动态SQL”的业务模块。登录和员工管理再怎么折腾都是单表CRUD菜品一来DTO、VO、事务、外键约束全部上场这才是进入真实业务开发的敲门砖。前面两天练的是“框架怎么用”从今天开始才算练“业务怎么做”。2. 公共字段自动填充把“谁在什么时候改的”交给框架2.1 先做个BaseContext用ThreadLocal记住当前登录人在写自动填充之前得先解决一个问题系统怎么知道当前操作的人是谁苍穹外卖的登录方案是JWT用户登录后后端签发一个包含员工ID的token前端在请求头里带回来拦截器解析后把员工ID取出。如果每次新增菜品都手动把这个ID从request里拿出来再set到实体上代码会非常啰嗦而且所有Service方法都得传一遍当前用户ID接口签名丑得没法看。更合理的做法是用ThreadLocal。ThreadLocal可以简单理解成“每个线程独享的一个箱子”同一个线程在处理请求的整个生命周期里往箱子里放的东西后面任何地方都能取到而且不同线程之间的箱子互不干扰。这样一个请求进来拦截器解析完token后把员工ID放进箱子到Controller、Service、Mapper层都可以直接开箱取。我写了一个BaseContext工具类public class BaseContext { private static ThreadLocalLong threadLocal new ThreadLocal(); public static void setCurrentId(Long id) { threadLocal.set(id); } public static Long getCurrentId() { return threadLocal.get(); } public static void removeCurrentId() { threadLocal.remove(); } }这里有个我必须提醒的细节一定要提供remove方法。Web服务器用的是线程池线程是被复用的如果处理完请求后不把ThreadLocal里的值清掉下一个请求拿到的是上一个请求的登录人ID这种错乱非常隐蔽查问题的时候会非常痛苦。我就见过把A员工的创建人写成B员工的情况最后发现就是漏了remove拦截器处理完请求后顺手调一下BaseContext.removeCurrentId()就好。2.2 自定义MetaObjectHandlerinsert和update各干各的活有了当前登录人ID之后接下来要让MyBatis-Plus在insert语句和update语句执行时自动往实体里填值。MyBatis-Plus提供了一个扩展点MetaObjectHandler只要实现它然后在实体类字段上标注填充策略即可。先说字段标注TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT) private Long createUser; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableField(fill FieldFill.INSERT_UPDATE) private Long updateUser;INSERT表示只在插入时填充INSERT_UPDATE表示插入和更新时都填充。这里面的设计逻辑很清晰createTime和createUser是“创建”这个动作产生的updateTime和updateUser是“每次修改”都要刷新的所以两者的填充时机天然不同。很多人把四个字段全部标成INSERT_UPDATE也能跑但从语义上说是错的记录创建人的地方被更新语句覆盖成修改人数据审计就没意义了。然后是实现MetaObjectHandlerComponent public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, createUser, Long.class, BaseContext.getCurrentId()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateUser, Long.class, BaseContext.getCurrentId()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, updateUser, Long.class, BaseContext.getCurrentId()); } }strictInsertFill的好处是自带空值检查只有实体里这个字段是null它才会去填充防止无意间覆盖掉业务代码手动设置的值。而且就算我们在handler里写了某个属性名只要实体类字段没有标fill策略它也不会生效等于天然多了一层过滤。2.3 为什么绕一圈用ThreadLocal而不是直接把ID传下去写到这里肯定有人问为什么绕一个ThreadLocal直接在Service层把用户ID作为参数传下去不是更简单表面上是的但如果每个Mapper方法都多一个userId参数所有接口的签名都会膨胀而且像MetaObjectHandler这种由框架回调的扩展点本来就拿不到Controller里的局部变量ThreadLocal是它跟业务上下文通信的最轻量方案。另外一个原因在于苍穹外卖里前端会并发操作ThreadLocal是线程隔离的A请求的ID不会串到B请求这是把“环境信息”放在请求线程里天然的好处。这里只要记住一句话凡是“这个请求是谁发起的、是否在请求链路里全局可见”的信息优先考虑ThreadLocal但务必记得用完清理。这个模式在后面的订单模块里还会反复用到比如获取当前登录用户、获取当前店铺状态都是同一个套路。3. 菜品图片上传本地落盘是从零开始最简单可靠的方案3.1 前端传的是什么后端该怎么接图片上传是新增菜品的前置条件因为新增菜品的表单里有个image字段存的就是图片的访问地址。前端用Element UI的Upload组件走后端一个通用上传接口发的是multipart/form-data也就是一个叫file的二进制文件。Controller方法写起来非常短RestController RequestMapping(/admin/common) public class CommonController { private String uploadPath D:/upload/; // 实际开发从配置文件读 PostMapping(/upload) public ResultString upload(MultipartFile file) throws IOException { String originalFilename file.getOriginalFilename(); int index originalFilename.lastIndexOf(.); String ext originalFilename.substring(index); // 拿到.jpg这样的后缀 String dateDir LocalDate.now().toString(); // 按日期分子目录 File dir new File(uploadPath dateDir); if (!dir.exists()) { dir.mkdirs(); } String fileName UUID.randomUUID().toString() ext; file.transferTo(new File(dir, fileName)); return Result.success(dateDir / fileName); } }这里有几个细节值得划线。第一文件名为什么要用UUID重命名。用户上传的文件可能叫“家常菜.jpg”也可能两个人上传了同名文件。如果直接用原文件名落盘第二次上传直接覆盖第一次前端页面上就会出现两张不同菜品照片共用同一个地址的诡异问题。UUID生成的哈希串基本不会重复解决重名问题最省事。第二为什么按日期分子目录。一个目录下面如果堆了几万个文件文件系统索引会变慢而且运维上很难按时间清理日期分目录是常见做法。我day03一开始没分目录功能也能跑但想到后面还有头像、套餐图片、营业执照图片全都往一个目录堆心里就不踏实后来还是补上了。第三后缀名一定要保留。有些同学只存UUID不带扩展名浏览器拿到图片流也能显示但后续做云存储、做CDN、做剪裁时没有类型信息会遇到麻烦。保留原扩展名还有一个好处是能在文件管理器里直观看到格式。3.2 静态资源映射把上传目录暴露给前端访问文件存到磁盘只是第一步前端要能在img标签里直接显示这个图片后端得告诉Spring“哪些请求路径对应磁盘上哪个目录”。我配了一个WebMvcConfiguration继承WebMvcConfigurationSupportConfiguration public class WebMvcConfiguration extends WebMvcConfigurationSupport { Value(${upload.path}) private String uploadPath; Override protected void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); } }配好之后前端上传接口返回的是“2024-06-01/xxx.jpg”那么图片的完整访问地址就是项目的域名或IP加上context-path再加/upload/2024-06-01/xxx.jpg。这里有一个我在Windows上反复踩的坑addResourceLocations的参数Windows下要写成file:D:/upload/Linux下要写成file:/usr/local/upload/。很多人在Windows本地测试没问题一部署到服务器就404十有八九是路径里的斜杠和盘符处理不对。我的做法是统一在配置文件里写upload: path: D:/upload/结尾的斜杠必须保留并且千万别忘了前面那个file:前缀。它表示这是一个文件系统路径不是classpath路径。如果你不小心写成了classpath:/upload/那Spring会在项目的resources目录下找显然找不到你刚存的文件。3.3 本地存储的边界能跑通但别满足于能跑通说实话本地存储方案在真实生产环境是不够的。多台服务器部署时图片传到A机器请求落到B机器就404了服务器重启可能会清空临时盘图片备份也非常麻烦。所以在苍穹外卖这个项目的后半段或者实际工作中通常会把上传接口的存储实现换成云OSS之类。day03阶段用本地磁盘的最大意义是把上传流程、文件流处理、资源映射这些基础概念先跑通后续换存储实现时只需要改Controller内部逻辑接口路径和返回结构不变前端无感知。这是“面向接口编程”的第一次直观体验。4. 新增菜品一次提交背后是DTO、两表插入和事务4.1 为什么Controller不能直接接收Dish实体新增菜品的前端表单字段比想象中多菜品名称、分类、价格、图片、描述、口味。口味是数组比如“不加辣/微辣/中辣/特辣”还可以有“大份/中份/小份”这种规格。数据库里口味存的是独立表dish_flavor一条菜品对着多条口味记录。如果Controller方法的参数直接写Dish实体只能接住菜品主表字段口味数组没法接收。如果写一个Map去兜底又失去了类型安全和可读性。正确做法是定义一个DishDTOData 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; }这里顺带把三层模型讲清楚Entity就是数据库表的映射Dish里只有dish表字段DTO是接口传输对象是在Entity基础上加上“本次请求额外带给我的东西”比如flavors列表VO是视图对象是往Entity基础上多带一些前端需要展示的字段比如分页查询需要categoryName。职责搞混了项目一大就会变成所有类都塞满字段的“上帝对象”改起来极其痛苦。这是day03真正该沉淀下来的东西。4.2 新增接口的Service实现两表插入必须打包成一个事务新增逻辑分成两步先往dish表插入主数据再把口味列表插入dish_flavor表。只要一步失败两边数据就脱节了所以一定要加Transactional注解。Service层代码核心逻辑如下Service public class DishServiceImpl implements DishService { Autowired private DishMapper dishMapper; Autowired private DishFlavorMapper dishFlavorMapper; Transactional public void saveWithFlavor(DishDTO dishDTO) { Dish dish new Dish(); BeanUtils.copyProperties(dishDTO, dish); // 默认状态先停售避免菜品没配好口味就直接在用户端上架 dish.setStatus(0); dishMapper.insert(dish); Long dishId dish.getId(); ListDishFlavor flavors dishDTO.getFlavors(); if (flavors ! null flavors.size() 0) { flavors.forEach(flavor - { flavor.setDishId(dishId); dishFlavorMapper.insert(flavor); }); } } }有几个细节值得单独说。第一dish.getId()在插入之后就有值前提是主键策略是数据库自增或者雪花ID。MyBatis-Plus默认的IdType是ASSIGN_ID生成雪花IDinsert后同样会回填到实体。所以这里千万不要在insert之前就想拿ID去关联口味顺序写反了必然得到null。第二为什么把默认status设成0停售。一个既没有图片也没配好口味的菜品如果一创建就启售用户端立刻就能看到并加购这显然是事故。把默认状态设为停售让管理员在后台确认菜品信息完整后再手动启售是符合真实业务场景的操作。我一开始没注意这一行后来自己独立写接口才发现它不是随便写的就是业务经验的体现。第三口味为空的情况。不是所有菜品都需要口味比如一瓶可乐就没有辣度选项。所以插入flavors之前必须判空否则前端口味不填时接口会报空指针异常前端也会收到一个不明不白的500。4.3 图片到底该什么时候传新增菜品的调用顺序是前端先调上传接口拿到图片URL再把URL随表单JSON一起提交给新增接口。也就是说上传和新增是两个独立请求不是同一个请求里既传文件又传菜品数据。这样做的好处是上传接口可以复用员工头像上传、套餐图片上传都可以走同一个接口缺点是垃圾文件会残留如果用户上传了图片但最后没有提交菜品这个图片就成了孤儿文件。完整的做法是加定时任务清理但day03阶段先知道这个隐患能理解顺序为什么这样设计就够了。5. 分页查询和修改回显一个讲透动态SQL一个讲透数据装配5.1 分页查询LEFT JOIN出分类名动态条件别漏状态菜品列表页要展示的信息包括菜品名称、所属分类、价格、图片、状态、更新时间。所属分类不在dish表里而在category表里所以不能简单地查dish表需要联表。苍穹外卖的持久层用MyBatis-Plus分页插件PageHelper在这里很顺手PageHelper.startPage(page, pageSize); ListDishVO list dishMapper.pageQuery(name, categoryId, status);关键工作在Mapper XML里select idpageQuery resultTypecom.example.vo.DishVO 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 if teststatus ! null and d.status #{status} /if /where order by d.create_time desc /select这里我吃了两次亏都值得讲一下。第一次是表名。如果只用单表查询dish然后用代码去查category补名字性能上是典型的N1问题页面上每行数据都要多打一次查询。列表接口要展示的数据多这个问题的放大效应很明显尽早改成JOIN一下就好。第二次是LEFT JOIN。为什么不写JOIN而是LEFT JOIN因为如果菜品被删了分类或者分类数据异常JOIN会导致菜品查不出来。LEFT JOIN保证菜品是主体分类查不到就显示null至少菜品列表是完整的。外键关系不强时用LEFT JOIN更稳妥。另外注意动态条件里要加上状态筛选。前端列表页有启售/停售的筛选开关如果漏了status这个参数筛完等于没筛。我自己写第一版就漏了还以为是前端传参问题查了半天才发现Mapper的XML里根本就没写status对应的if标签。5.2 修改回显一次查询查主表一次查询查口味点击菜品列表的“修改”按钮时前端需要把当前菜品信息回填到表单里包括基本信息、图片、口味。这个时候要查询两次public DishVO getByIdWithFlavor(Long id) { Dish dish dishMapper.selectById(id); ListDishFlavor flavors dishFlavorMapper.selectByDishId(id); DishVO dishVO new DishVO(); BeanUtils.copyProperties(dish, dishVO); dishVO.setFlavors(flavors); return dishVO; }这个接口本身不难但要注意前端拿到的是DishVO而不是Dish因为口味列表是前端编辑表单需要的。如果后端只返回基本字段前端就还得再发一次请求查口味接口设计就不完整了。这里的DishVO和分页查询的DishVO可以是同一个类里面多一个flavors字段分页列表时虽然用不上这个字段返回一个空集合也不会出错。5.3 更新时为什么要“先删后插”修改菜品的提交逻辑比新增要复杂一点。新增是第一次插入修改是可能要改名称、改分类、改价格还可能把原来的口味删掉几个、新增几个。如果逐条判断哪些口味需要更新、哪些需要删除代码要写一堆状态对比还容易出错。项目里通用的做法是先update dish表更新基本字段delete from dish_flavor where dish_id ? 把旧口味全部删掉再把前端传过来的新口味列表循环insert。这个“先删后插”看着粗暴但能保证口味列表和前端提交结果完全一致而且不用为每条口味做存在性判断性能上也在可接受范围内。因为有Transactional罩着就算第三步失败也能回滚不会出现“菜品改了但口味清空了”的数据残缺。我第一次写更新时只更新了dish表忘了动flavors结果前端修改菜品名称后再改口味保存之后出现新旧口味叠加的情况。后来照着先删后插的思路改完数据就干净了。这里也提醒大家凡是“一对多”子表跟着主表整体编辑的场景都可以考虑先删后插而不是逐条update。6. 启售停售和删除CRUD之外最值得品的一课6.1 status字段承载的业务含义远远超过“一列数据”启售停售接口本身很简单PostMapping(/status/{status}) public ResultString updateStatus(PathVariable Integer status, Long id) { Dish dish Dish.builder() .id(id) .status(status) .build(); dishMapper.updateById(dish); return Result.success(); }三行代码而已但它是整个外卖业务的开关。菜品启售了用户端小程序里就能看到、能加购停售了用户端立刻消失但历史订单里这个菜品还在。这比“删除菜品”对未来订单的影响小得多。所以外卖管理系统的真实业务里宁可大量使用启售停售也不轻易删除菜品数据因为订单表外键、对账、审计都牵扯着历史数据。这个理解很重要。很多人做完CRUD就觉得自己会了但真正到面试或者工作里面试官问的不是“你怎么把状态改成0”而是“为什么这个功能用状态而不是删除”。day03让我第一次意识到业务规则才是代码设计的前提。状态字段看起来只是0和1背后是整个店铺的运营逻辑。6.2 删除菜品必须先查套餐引用删除接口可不是把dish表删掉那么简单。苍穹外卖里有套餐功能套餐是由多个菜品组成的比如“两人餐”套餐里包含A菜品和B菜品。套餐和菜品的关联关系存在setmeal_dish表。如果一个正在被套餐引用的菜品被删了套餐详情页就会出现菜品名还在、实际已经查无此菜的情况。所以删除前要查引用int count setmealDishMapper.countByDishIds(Arrays.asList(ids)); if (count 0) { throw new BusinessException(菜品被套餐引用无法删除); }对应的SQL也简单就是一个IN计数select idcountByDishIds resultTypeint select count(*) from setmeal_dish where dish_id in foreach collectionids itemid open( separator, close) #{id} /foreach /select这里有个容易忽略的地方批量删除时ids是数组。前端传参格式一般是/delete?ids1,2,3Controller里用Long[] ids接收Spring会自动把逗号分隔的字符串拆成数组。自己用Postman测试时也要注意路径参数或者Query参数里直接写逗号分隔的字符串就行。6.3 批量删除选择“整体失败”还是“跳过部分”继续把批量删除想深一层如果ids里有三个菜品其中两个没被套餐引用一个被引用了怎么处理方案一只要有引用整个请求抛异常什么都不删。优点是操作一致性有保证前端可以明确提示用户哪些不能删缺点是用户一次想删三个结果一个都删不成体验一般。方案二能删的删掉不能删的跳过。优点是操作部分完成缺点是有时用户以为全删了结果部分还在需要额外的返回信息告知结果。苍穹外卖里用的是方案一因为后端一旦发现count大于0就抛异常事务回滚。这也提醒我平时设计批量接口时要提前想清楚到底采用哪种策略不要出现“删除了一半报错”的中间状态。另外还要说一句删除菜品时不需要同步删除磁盘上的图片文件。理由是图片地址字段存在dish表里如果菜品删了但套餐历史记录里还引用着这张图把文件也删了就真的404了。图片文件本身是无状态的公共资源定期清理孤儿文件是单独的任务不要在业务删除里顺手做否则容易误删。7. day03复盘我真正沉淀下来的东西今天一天的实际产出其实是七个接口上传、新增、分页、回显、修改、启售停售、删除。但比接口数量更重要的是我终于把几个以前零散知道的知识点串起来了。第一ThreadLocal不是八股文它在请求链路里传递登录人身份这一步是真实刚需用完清理是铁律。第二DTO、VO、Entity的分层更不是概念游戏菜品这个业务一上来不分层代码就散架。第三先删后插、默认停售、删除前查套餐引用这些看起来不起眼的判断才是从“能写代码”到“写得像生产代码”的分界线。顺手整理一下今天踩坑的清单场景坑点正确做法ThreadLocal线程复用导致登录人串号请求结束调用remove清理文件上传原文件名直接存储导致覆盖UUID重命名按日期分子目录静态资源Windows/Linux路径格式不一致配置统一路径保留file:前缀分页查询漏写状态筛选条件动态SQL每个条件都要配if标签修改口味只更新主表口味新旧叠加先删后插配合事务回滚删除菜品未检查套餐引用直接删除先查setmeal_dish引用计数如果你是跟这个项目或者正在学类似的管理后台我给的建议只有一条每个接口写完不要看接口返回成功就收工用Postman或者直接把前端页面开出来自己注册一个测试员工走一遍“上传图片—新增菜品—分页查询—修改—启售—停售—删除”的完整链路。只有把链路串起来跑过你才会在报错的一瞬间真正理解那些表和字段为什么这样设计。明天应该要进套餐管理和订单模块了订单那块牵扯到的状态流转比菜品复杂得多到时候再写新的学习记录。