苍穹外卖这套项目我前前后后给不少人讲评过,自己也完整复现过不止一遍。到了Day02这个阶段,项目算正式从"登录跑通"进入到"业务铺开"的关键节点:员工管理、分类管理、菜品管理三大模块全部在这天落地,同时会碰到一个几乎所有Web系统都绕不开的需求——图片上传。这一天的技术总结拖到现在才完整写出来,主要想把本地上传图片这条链路单独拎出来好好聊聊,因为很多人在这一块吃了不少暗亏。
先说清楚Day02到底在做什么。前面Day01搭好了环境,跑通了登录,用JWT解决了身份认证。Day02就要在登录态的基础上把管理端的核心基础数据模块撑起来:员工的分页查询、启用禁用、编辑信息,分类的新增删除修改和分页,最后是菜品的增删改查。而菜品必须有图片配图,这就自然引出了文件上传下载的需求。项目里给出的标准做法是先做"本地上传图片"方案,也就是把文件存到服务器本地磁盘,再通过静态资源映射把图片回显出来。这套链路短、依赖少,特别适合理解上传的完整逻辑,也是后面切换云存储之前必须吃透的基础功。
1. 内容整体设计与思路拆解
1.1 Day02的功能版图与依赖关系
Day02的三个模块不是平白堆在一起的,它们之间有清晰的业务递进关系。员工模块解决的是"谁在操作系统"的问题,分类模块解决的是"菜品怎么归类"的问题,菜品模块则是前面所有功能的集大成者——因为新增菜品时要选择分类,还要上传菜品图片。没有员工管理和分类管理打底,菜品管理就是空中楼阁。很多初学者容易犯的毛病是跳着学,先去做看着有意思的文件上传,结果回头写菜品时发现DTO里要带上分类id,连分类数据都没建,联调时一脸懵。
从代码结构上看,Day02最终落地的Controller、Service、Mapper三层模式,跟前一天登录功能的写法保持一致。每个模块都是"界面层接收参数、业务层处理逻辑、数据层操作数据库"的标准套路。这种设计在企业项目里的好处是直观可维护,出问题能快速定位在某一层,也方便后续在Service里加事务、加缓存,不用改动接口签名。
1.2 为什么先做本地上传而不是云存储
苍穹外卖课件里文件上传部分其实给了两个方向:一个是本地存储,一个是阿里云OSS。实际教学和自学的路径上,我强烈建议先把本地上传完整走一遍。原因特别简单:云存储的本质也是"上传文件到远端",只是把存储介质从本地磁盘换成了对象存储桶,客户端从直传服务器变成了直传云。本地上传让你把整个请求链路看清楚——MultipartFile是怎么封装文件的、文件流怎么落盘、访问路径怎么映射回去,这些底层机制搞明白了,换OSS只是换一个接收端的事,心智负担小得多。
以我看到的学员反馈来说,凡是本地上传理解透彻的人,切换到OSS方案基本半天就搞定。反而是一上来就用OSS的同学,出了问题连"到底是我没传上去,还是服务器没收到,还是访问路径不对"都分不清。这不是说OSS不该学,而是学习的顺序要符合认知规律,先本地后云端,错了也好排查。
2. 员工管理模块的实现细节
2.1 ThreadLocal传递登录用户ID的用法与坑
员工管理里最值得讲的不是CRUD本身,而是"修改员工时自动记录修改人"这个设计,它涉及整个项目里一个重要工具类——BaseContext。流程是这样的:JWT拦截器解析Token时拿到当前登录员工的id,把这个id存进ThreadLocal;后续Service层需要"当前操作人是谁"时直接读取;请求结束在拦截器的afterCompletion里调用remove清理。这个设计的本质是避免在每个业务方法里都显式传递userId参数,让代码干净很多。
实际使用时有一个特别隐蔽的坑:Tomcat线程池的线程复用。如果afterCompletion里没有清理ThreadLocal,下一次请求被同一线程处理时会读到上一次残留的用户id,造成修改人和实际登录人不一致。这在测试环境很难发现,等上线后多人并发操作时才暴露,排查起来非常痛苦。所以BaseContext里一定要留一个removeCurrentId方法,并且确保在拦截器最终环节调用。我后来养成的习惯是每次请求的finally块里主动清掉,层层保险。
2.2 PageHelper分页查询与DTO/VO转换的实操要点
员工分页查询用的是PageHelper这个分页插件,用法可以用"三步走"概括:Service层调用PageHelper.startPage(page, pageSize)后紧接着执行查询,然后把结果封装成PageResult返回。这里有两点容易踩雷。第一,startPage和查询之间不能插入任何其他查询语句,否则分页拦截器会把插件作用到错误的SQL上,这就是PageHelper"就近生效"的规则。
第二,PageHelper返回的Page对象是查询出来的实体集合,如果直接用Entity返回前端,会发现多出很多不该暴露的字段,比如密码的Hash值。所以员工管理单独定义了EmployeeDTO(接收参数)和EmployeeVO(返回前端),用Spring的BeanUtils.copyProperties做属性拷贝。这个转换过程一开始会觉得繁琐,但做多了就知道它其实是保护层——数据库表结构是内部实现,前端展示字段是外部契约,两者做到解耦,后面表加字段也不会影响接口稳定性。
2.3 启用禁用员工的状态切换实现
启用禁用员工这个功能看着简单,就是把status字段从0改成1或反过来,但它有一个必须注意的细节:不能修改当前登录账号自己的状态。前端可能做了限制,但后端绝不能只依赖前端,接口层要判断操作目标id和当前登录id是否相等,相等就直接拒绝。这在真实项目中就是一条硬性业务规则,测试时必须专门验证。还有一点,Service方法上要加@Transactional,因为状态切换涉及先按id查询再更新的两步操作,虽然不是强一致要求,但加上事务总归稳妥。
3. 分类管理:增删改查背后的业务约束
3.1 分类类型的编码设计与前端联动
分类管理里有个type字段,1代表菜品分类,2代表套餐分类。这个数字编码的设计在企业系统里非常常见——用数字而不是字符串存数据库,查询效率高,传递体积小,前端拿到数字再渲染成中文标签。新增分类时后端要校验type只能传1或2,不能传其他值,这是接口健壮性的第一道防线。
分类排序的sort字段同样值得留意。课程里的做法是允许用户手动输入整数控制排序权重,数字小的排在前面。这里的业务逻辑是排序值允许为空或重复,如果为空就赋默认值0。分页查询时用ORDER BY sort ASC排序,保证列表顺序稳定。此类细节不亲手写过一遍,很难意识到"查询列表必须有明确排序规则"这个约定,有时候MySQL不加order by也能查到数据,但翻页时会莫名重复或缺失,其实都是排序不稳定惹的祸。
3.2 删除分类时如何处理关联数据
分类删除是当天最容易报错的一个接口。原因是:分类表和菜品表之间存在外键关联,一个分类下如果已经有菜品,直接DELETE FROM category会触发数据库的外键约束错误,或者说删除后菜单数据变成孤儿数据。合理的做法有两种,项目里建议的是业务层面处理——先查询该分类下是否存在关联菜品或套餐,存在则抛出业务异常,提示"当前分类关联了菜品,不能删除",由全局异常处理器统一捕获返回给前端。
我在给自己项目做扩展时还加过第二种策略:逻辑删除。即不真正删数据库记录,而是加一个deleted字段,查询时统一过滤。这种做法在C端电商系统里尤其常见,因为历史订单需要回溯当时的分类名称,物理删除会让历史数据失真。Day02阶段用物理删除能少写很多判断逻辑,但心里要清楚这只是教学简化,真实生产环境要慎重。
3.3 修改分类的时间字段自动填充
分类修改接口除了更新name、sort、type之外,还要维护更新时间字段。项目里这个模块没有引入MyBatis Plus的自动填充功能,而是手动作业:查询当前时间new Date()塞进实体,再执行update。写多了会发现每个模块都在重复这段代码,这时候就推出了一个公共字段填充的优化思路——在MyBatis的XML里用数据库函数NOW()统一处理createTime和updateTime,代码层完全不用管。两种方式各有适用场景,手动方式逻辑透明,自动方式减少重复,在生产环境按团队规范选择即可。
4. 本地上传图片:从上传到回显的完整链路
4.1 前端el-upload组件接入与代理配置
这部分是热词"苍穹外卖本地上传图片"的绝对核心。前端页面用的是ElementUI的el-upload组件,新增菜品弹窗里选择图片时,组件需要配置一个action属性指向后端的上传接口地址。在典型vue-element-admin结构下,前端请求会经过开发服务器的proxy代理转发到后端的8080端口,所以action填的是代理路径如/api/admin/common/upload,而不是直接写localhost:8080。这种配置新手很容易配错,配成直连后端会出现跨域问题,浏览器报CORS错误。
el-upload的on-success回调会在上传完成后拿到后端返回的JSON,里面带着图片的访问路径。拿到之后把它赋值给表单的image字段,同时把图片地址绑定到img标签的src上进行预览。这里要注意:后端返回的只是一个相对路径,比如/upload/xxx.jpg,浏览器要用这个路径访问图片,前端也需要配置代理,把/upload前缀同样转发到后端服务。很多人在这一步漏配,导致后端明明存好了图片,前端却怎么都显示不出来,这个问题我在第5章排查表里还会重点说。
4.2 后端接收MultipartFile与UUID重命名逻辑
后端CommonController里定义一个upload方法,参数用MultipartFile接收,代码大致是这样:
@PostMapping("/upload") public Result<String> upload(MultipartFile file) { if (file == null || file.isEmpty()) { throw new BusinessException("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); if (!suffix.matches("\\.(jpg|jpeg|png|gif|webp)$")) { throw new BusinessException("图片格式不支持"); } String fileName = UUID.randomUUID().toString() + suffix; // 后续为存储路径逻辑... return Result.success("/upload/" + fileName); }这里有几个关键决策值得说一下。用UUID重命名文件,是为了避免两个用户上传同名文件时互相覆盖。曾经有同学图省事直接拿原始文件名存盘,测试时反复传同名图片,结果每次传完,前面的图就被覆盖了,页面只能看到最新那一张,排查半天才发现是命名惹的祸。加UUID之后理论上不会重复,因为UUID的碰撞概率可以忽略不计。保留原始后缀名则是为了浏览器识别图片类型,如果你硬要说后缀不可信,那就在读取时用文件的Content-Type再校验一道,双重保险。
4.3 本地存储路径的选择与目录自动创建
存储路径是整个上传功能里坑最多的位置。一种做法是通过ServletContext获取运行目录的真实物理路径,也就是项目部署的target/classes目录,在这个目录下拼一个upload子目录来存放文件。另一种做法是直接使用用户当前工作目录user.dir,在项目根目录下生成upload文件夹。两种方案都能跑通,但差异很关键:target目录会在每次clean和重新编译时被清除,文件会消失;项目根目录则稳定得多,比target方式安全。
目录路径确定后要记得mkdirs(),因为首次运行时upload目录并不存在,直接transferTo会抛IOException。不少新手就是漏了这一行导致第一张图上传必失败,后台却只看到一句笼统的文件写入异常。还有字符编码问题:路径里不要写死中文或空格,Windows下还可能会出现斜杠转义问题,统一用正斜杠拼接最省心,Java的File类对正斜杠的兼容性很好,移到Linux服务器上也不会报错。
4.4 图片回显的静态资源映射配置
文件存到磁盘后,前端怎么访问它?Spring Boot默认的静态资源目录是classpath:/static/、classpath:/public/这类位置,而upload目录位于文件系统里,不在classpath中,所以必须显式配置资源映射。在WebMvcConfigurer的addResourceHandlers里把/upload/**这个URL前缀映射到本地磁盘目录:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + System.getProperty("user.dir") + "/upload/"); } }注意addResourceLocations的结尾一定要带正斜杠,并且file:前缀不能省,这是Java URL对象解析资源地址的硬性要求。少了斜杠,Spring会把路径解析成upload文件而不是upload目录,结果就是访问所有图片都404。这个细节非常反直觉,我在本地复现和帮学员排查时至少遇到过五六次。
4.5 文件大小限制与全局配置调整
Spring Boot对上传文件大小默认限制是1MB,超过直接抛出MaxUploadSizeExceededException。菜品图片动辄两三M,这个默认值显然不够用,所以要在application.yml里调大:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MBmax-file-size限制单个文件,max-request-size限制一次请求携带的全部数据量。如果前端批量上传,后者设小了同样会失败。调完这两个配置后,建议顺手在全局异常处理器里捕获文件大小超限的异常,给前端返回一段友好的提示文字,而不是默认的错误堆栈。我见过很多项目在这里偷懒,用户传个稍大的图直接看到一屏英文报错,体验极差。
4.6 本地上传方案的边界与升级方向
本地上传虽好,但它的局限必须认清:磁盘扩容麻烦,多实例部署时无法共享文件,重启可能丢数据,图片访问没有CDN加速。所以理解这套链路后,要能清楚地画出升级路径——把MultipartFile直接交给OSS的putObject方法,返回的URL存入数据库image字段,前端src直接用完整URL,代码改动其实只集中在Controller层。Day02要求掌握的并不是某个具体存储方案,而是抽象出来的"接收文件-存储-回显-访问"这套通用逻辑,这个认知才是最有迁移价值的。
5. 常见问题与排查技巧实录
5.1 图片上传成功但前端显示404
这个问题的出现频率在所有问题里排第一。现象是上传接口返回成功,数据库里也存了路径,但img标签渲染时图片裂了。排查步骤就三步:先在浏览器地址栏直接访问返回的URL,看能不能打开;如果404,再去确认前端代理配置是否包含/upload前缀;如果没配置,就打开开发者工具看请求报错类型,是404还是CORS,一锤定音。很多人绕来绕去半天,其实就是漏了vue.config.js里加一个upload的proxy配置。
5.2 重新编译项目后之前上传的图片全没了
如果图片存储在target目录下,每次Maven clean都会把整个target清空重建,历史图片自然跟着消失。这个问题在IDEA里特别普遍,因为IDEA用集成终端运行Spring Boot时,默认工作目录就是项目根目录,但getRealPath获取的却是target/classes,两者混在一起思考就会出错。解决方案是统一采用user.dir方式建upload目录,并建议在.gitignore里忽略upload文件夹,防止开发机上的测试图片不小心提交到代码仓库。
5.3 文件名后缀校验被绕过的问题
只用substring截取后缀并不算安全检查,因为前端可以伪造扩展名。比如上传一个实际上内容是脚本的文件但命名为.jpg,浏览器和后端都不一定拦得住。我在实际项目中额外加了Content-Type校验和文件头校验,读取文件前4个字节比对魔数判断真实类型,比如JPEG的FF D8 FF开头、PNG的89 50 4E 47。这个方法在防御普通用户误操作时足够用,生产环境更严格的做法是接入服务商的文件检测服务,但这已经超出Day02的范围,了解即可。
5.4 分页查询时第二页数据重复或缺失
这个问题的根源通常是排序字段有重复值。分类表的sort字段如果多条记录都是同一个值,MySQL在没有唯一排序条件时返回顺序不稳定,翻页时会出现同一条记录出现在两个页面的现象。解决思路是排序语句加上唯一字段兜底,比如ORDER BY sort ASC, id ASC,保证排序结果完全确定。员工表同样道理,按创建时间排序时要把id加进去。任何分页SQL都建议遵循这个"排序字段+唯一字段"的双保险原则。
6. 一些关于Day02的复盘心得
Day02全流程走完,我自己最深的体会有两个。第一,学这类业务系统切忌只跟着敲一遍,一定要在正确理解设计意图的基础上做增量改动,比如自己给分类模块加一个启用禁用状态流转,给菜品模块加批量起售停售,这些小改造会让你对接口设计、事务边界、权限判断的理解远超原课程。
第二,本地上传图片这个看似基础的功能,实际上是理解整个Web系统中"文件流"的重要窗口。从浏览器发出的字节流,到Servlet容器的封装,再到磁盘的落位,最终通过URL寻址回到浏览器,整个链路覆盖了HTTP协议、文件IO、静态资源服务、前端代理等多个知识面。能在Day02阶段把它彻底吃透,后面做导出报表、批量导入Excel、OSS直传这些功能都会顺手得多。这也是我愿意花那么大篇幅记录这天的原因,基础打牢了,往上盖楼才不慌。