ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

苍穹外卖菜品管理实践:本地上传图片与静态资源映射全解析

苍穹外卖菜品管理实践:本地上传图片与静态资源映射全解析 苍穹外卖这个项目今天进入第三天。前两天的进度还算顺利环境搭好、框架拉通、员工登录和JWT拦截器也跑通了数据库里能用表都建好了。今天给自己定的目标是啃下菜品管理这条线尤其是热词里反复提到的“苍穹外卖本地上传图片”——说白了就是让前端在新增菜品时能传一张图片到服务器后端存好并返回访问地址前端再用这个地址做回显。听起来简单实际做下来里头的细节是真不少这篇日记就把今天完整的实操过程、踩过的坑和排查思路都记录下来。1. 第三天进度规划与整体设计思路1.1 今天要完成哪些功能点第三天从整体节奏上看正好处在项目“骨架已通、五脏待填”的阶段。前面两天已经完成了项目工程骨架、数据库表设计、公共返回类、全局异常处理器、员工端登录、退出、JWT令牌鉴权、拦截器放行配置这一整套底子。如果继续往后推进常见顺序是员工管理模块但我自己规划项目时习惯把菜品管理提前一步因为菜品是外卖业务的核心实体菜品管理涉及的分页查询、新增、图片上传、起售停售、修改、删除几乎覆盖了业务开发的经典动作练一遍顶三遍。所以今天这张任务清单是这么列的完成菜品分页查询带菜品种类名关联解决分类表联查问题完成本地上传图片接口返回可访问的图片URL完成新增菜品功能包含图片回显、口味数据组装、分类ID校验顺手把分类下拉列表给前端准备好因为新增菜品从页面逻辑上必须依赖这个接口。这四件事里分页查询是基础图片上传是今天的主要矛盾新增菜品是把两者串起来的业务闭环。1.2 技术方案选型为什么本地存储是一步好棋苍穹外卖这种项目网上很多参考代码会直接上来接OSS也就是阿里云对象存储把文件传到云端返回一个公网URL。这样做确实省事大厂生产环境也会选择云存储但放在学习阶段我会强烈建议先用本地存储。原因特别现实第一云存储要开通服务、配置AccessKey、理解Bucket和Endpoint的概念新人容易被这些外围配置消耗掉宝贵的学习精力第二接云存储之后什么问题都被封装好了你看不到文件到底存哪了、URL是怎么拼出来的、静态资源映射是怎么配的反而丢掉了一次理解HTTP静态资源访问机制的绝佳机会。本地存储的方案本质上是把“文件系统”当做一个简单的对象存储来用上传接口接收MultipartFile把文件字节流写到服务器某个指定目录然后构造一个URL路径返回给前端。前端拿到URL赋值给img标签的src浏览器再次发起GET请求后端通过静态资源映射把这个请求转成物理文件的读取。这一步打通之后以后再适配OSS只是换一个存储策略的问题前面的Controller、Service逻辑都可以保持不动。1.3 当天开工前的工程状态检查下午一点多开始写代码之前我先花了一点时间检查了当前工程的整体状态避免带着潜在问题往下走。跑了一遍所有已有的单元测试确认登录接口和JWT相关的token生成校验没有被之前的改动影响看了下数据库里dish、category、dish_flavor这几张表的结构主要是字段类型和约束关系还把application.yml里已有的配置重新过了一遍。这个习惯说实话是踩过几次坑之后才养成的很多开发越做越乱都是因为前期欠了技术债没有及时发现后期一边加新功能一边等莫名其妙的报错出现。工程状态确认没问题之后就开始做今天的正式功能开发。整体思路是先建上传接口再写静态资源映射最后做菜品分页和新增。2. 苍穹外卖本地上传图片功能完整实现2.1 配置本地存储路径与静态资源映射图片上传的第一步是把存储目录配到配置文件里而不是硬编码在Java代码中。我个人的习惯是先在application.yml下面加一个自定义配置段这样后续换目录或换存储方案时只需要改配置不需要重新编译。配置内容是这样加的sky: upload: path: D:/sky_upload/这个路径是Windows下的写法如果后面部署到Linux服务器改成/opt/sky/upload/就行。配置写好后在Java代码里用ConfigurationProperties或者Value去读取它。我这里用的是ValueConfiguration public class WebMvcConfig implements WebMvcConfigurer { Value(${sky.upload.path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); } }这里有个关键点Spring MVC默认只处理classpath下静态资源像classpath:/static/这样的路径。但上传的文件是动态运行时产生的不可能打包进jar里所以必须用addResourceHandler手动把/upload/**这个URL模式映射到本地物理磁盘目录。代码里的file:前缀是必须的它告诉Spring这代表一个文件系统路径而不是classpath路径。少了这个前缀资源映射就会失效访问图片时会直接404。配置完成后我顺手验证了一下在D盘的sky_upload目录下放了一张测试图片然后浏览器访问http://localhost:8080/upload/test.jpg如果页面能正常渲染出图片说明静态资源映射生效这一步过关。2.2 上传接口的Controller层设计Controller层的设计我放在了CommonController里这是一类通用的、和业务实体无关的接口集合。为什么放到这里而不是做一个专门针对菜品的上传接口因为后续品牌管理、员工头像、套餐图片等地方都会有上传图片的需求但上传逻辑本身是完全一样的——都是接收文件、存储、返回URL。把这段逻辑抽成公共接口避免以后每个模块写一套重复代码。看下我写的接口代码RestController RequestMapping(/admin/common) public class CommonController { Autowired private UploadService uploadService; PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { String url uploadService.uploadImage(file); return Result.success(url); } }这里用了统一的返回类Result并把真实的图片访问地址放在data字段里前端拿到之后可以直接用来回显。MultipartFile是Spring提供的文件上传封装类型接收前端上传的文件流支持获取文件名、大小、输入流等基本信息。注意RequestParam(file)里的参数名要和前端提交时的字段名保持一致否则会报Required request part file is not present的错误。2.3 Service层的核心逻辑与文件名生成策略Service层才是上传逻辑真正发挥作用的地方。我先是写了一个通用接口UploadService实现类叫LocalUploadServiceImpl这个名字本身就说明了策略当前用本地存储实现上传。以后扩展OSS只要再写一个OssUploadServiceImpl实现同一个接口然后在配置里切换即可。看核心代码Slf4j Service public class LocalUploadServiceImpl implements UploadService { Value(${sky.upload.path}) private String uploadPath; Override public String uploadImage(MultipartFile file) { // 1. 文件非空校验 if (file null || file.isEmpty()) { throw new BusinessException(上传文件不能为空); } // 2. 获取原始文件名 String originalFilename file.getOriginalFilename(); // 3. 提取文件后缀名例如 .jpg .png String extension ; if (originalFilename ! null originalFilename.contains(.)) { extension originalFilename.substring(originalFilename.lastIndexOf(.)); } // 4. 生成唯一的文件名 String newFileName UUID.randomUUID().toString() extension; // 5. 确保目录存在 File dir new File(uploadPath); if (!dir.exists()) { dir.mkdirs(); } // 6. 保存文件 File targetFile new File(uploadPath newFileName); try { file.transferTo(targetFile); } catch (IOException e) { log.error(文件上传失败: {}, e.getMessage(), e); throw new BusinessException(文件上传失败); } // 7. 返回可访问的URL return /upload/ newFileName; } }这段代码的核心点也是这个功能最容易被忽视但实际最重要的一个点文件名生成策略。我直接使用了UUID.randomUUID()这里一定要克制那种想用原始文件名的冲动。为什么因为用户上传的图片很可能叫美食.jpg、我的照片.png如果直接用原名保存两个不同用户传了相同名字的文件就会相互覆盖这是绝对的灾难。另外原始文件名可能存在中文、特殊字符直接暴露在URL里会产生编码问题而UUID保证了全球唯一性不重名、不冲突、没有编码烦恼。后缀提取用的方法是判断最后一个.的位置然后截取子串。这一步主要是为了拼接出新文件的扩展名比如原始文件叫红烧肉.jpg新文件名就是6f3d4c2e-1234-4f56-9abc-7def0a1b2c3d.jpg。2.4 文件大小限制与上传性能考量本地存储方案虽然简单但不意味着可以忽略对上传文件的必要限制。默认情况下Spring Boot对上传文件大小有个限制大约是1MB如果用户传一张高分辨率照片很容易就超出限制。所以我在配置文件里调整了上传的最大值spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MBmax-file-size表示单个文件上限我给到10MB对菜品图片来说绰绰有余max-request-size表示一次请求中所有文件的总大小上限20MB足够应对多图场景。这组参数的调整会让Spring在文件大小超标时自动抛出MaxUploadSizeExceededException异常配合前面的全局异常处理器就可以把对应的错误信息翻译成友好提示返回给前端。另外文件保存用的file.transferTo(targetFile)方法对于MultipartFile来说是最推荐的保存方式它底层根据文件大小自动判断使用内存还是临时文件方案能有效避免小文件频繁读写磁盘带来的性能浪费。如果你见到有人用file.getInputStream()再手动拷贝那种做法也不是不行但代码要多很多还容易因为忘记关闭输入流造成文件句柄泄漏。能用transferTo绝不多写一行。3. 菜品管理闭环分页查询、回显与新增菜品3.1 菜品分页查询与分类名联查上传接口通了之后今天就该把它们接进菜品的业务流程里。第一个业务动作是菜品分页查询。前端菜品管理页面打开时默认要展示当前所有菜品信息以分页形式呈现每一行菜品要能显示出所属分类名称。菜品表里只有分类ID没有分类名真正的分类名在category表里。所以查询时要用JOIN操作把两张表关联起来select idpageQuery resultTypecom.sky.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这段SQL里的LEFT JOIN用得很讲究。虽然业务上菜品一定有分类但LEFT JOIN能保证即使某些历史数据因为误操作丢失了分类关联查询结果也不会缺行最多分类名为空。动态条件用 标签配合 判断实现多条件可选查询。这里我不会把SQL写在Service层而是全部保持在Mapper XML文件中保持分层清晰。分页查询用PageHelper插件实现Service层只需要两行代码。这里有实际操作中容易踩的坑PageHelper的分页只对紧接着它后的第一条查询语句生效所以Service里执行PageHelper.startPage()之后必须立刻调用Mapper的查询方法中间不要插入任何其它查询操作否则分页参数就作用到别的查询上去了一旦出错很不好排查。3.2 新增菜品时的图片回显流程菜品新增页面的交互逻辑是这样的页面上会出现一个菜品图片区域下面有个“上传图片”按钮。用户点击按钮选择本地图片前端JS拦截到选择的文件后立即通过multipart/form-data格式向/admin/common/upload接口发起POST请求。后端收到文件、保存到本地返回JSON数据里面包含图片的访问URL。前端拿到这个URL后把它赋给一个隐藏input框存储同时更新图片区域的src属性实现立即回显。等用户把所有信息填完点“保存”按钮时页面提交的是整个菜品表单数据其中包括隐藏在表单里的图片URL字段。后端新增菜品时只需要把这个URL字符串原样存到dish表的image字段中即可。理解这个流程的关键点在于图片上传和菜品保存是两个独立的请求前者先把文件落到服务器后者再引用文件的访问地址。这种做法比把图片文件Base64编码塞进JSON体里好得多既节约请求体大小又方便后续在菜品列表页直接通过URL加载图片。3.3 口味数据与菜品表单的组装苍穹外卖的菜品模型里有一个特殊结构——口味。比如一份炒饭可能有“微辣、中辣、特辣”几种口味这些数据存在dish_flavor表。新增菜品时前端会把口味数据以JSON数组形式提交对应的DTO里就有一个List 字段。接收这类嵌套结构时只需要在Controller方法参数上添加RequestBody注解Spring就会自动完成JSON反序列化把Json数组转换成Java的List对象。保存口味的逻辑需要在事务里完成先插入菜品主表拿到自增主键ID再把菜品ID逐个设置到每个口味对象的dishId字段上最后批量插入dish_flavor表。注意这里的顺序问题主键必须在主表插入后获取然后再去插入从表。很多新人在这里会写出先插口味表再去查询菜品ID的错误代码逻辑上完全颠倒了。事务由Transactional注解保证任一一步失败就整体回滚避免出现菜品有记录但口味没有的脏数据。3.4 图片上传与菜品管理的接口联调记录所有代码写完我用Postman做了几组接口联调测试。第一组是上传接口随便找了一张jpg图片用form-data格式加到file字段POST到/admin/common/upload接口返回200和/upload/xxx.jpg然后把返回的URL拼上域名浏览器直接打能打开图片说明接口通、映射也通。第二组是新增菜品先获取分类列表取一个分类ID在新增请求里带上分类ID、菜品名称、价格、图片URL、口味数组提交后数据库新增一条dish记录同时dish_flavor表新增两条口味记录data保持一致。第三组是回显验证调用分页查询接口返回的列表里categoryName有值图片URL能被前端img标签正确渲染。整个过程跑了两遍第一遍在口味接收上踩了个小坑前端提交的JSON里口味字段名叫flavors但DTO里我定义的是flavorList反序列化时Spring找不到对应字段导致口味数据为空。后来在DTO上加了一个JsonProperty(flavors)注解才把映射关系调整过来。这个坑很有代表性前后端字段命名不一致是开发中最常见的联调问题遇到这种问题先不要急着查后端逻辑先看前端的JSON结构长什么样、后端期望的字段名是什么这两者必须严格对应。4. 常见问题与排查技巧实录4.1 上传成功但图片无法访问的静态资源映射问题今天做这个功能时遇到最典型的问题就是上传接口返回200文件也确实保存在了指定目录里但浏览器打开返回的URL就是404。排查过程很有教学价值最终发现是WebMvcConfig这个类没有被Spring扫描到。我的项目结构里启动类放在com.sky包下而WebMvcConfig也放在com.sky.config包内理论上能被扫描到但检查后发现我当时把配置类放在了主包的上级目录导致组件扫描完全没有覆盖到它。修复方法很简单把配置类移动到com.sky.config包下或者直接在启动类上添加ComponentScan指定更宽泛的包路径。这个坑提醒我Spring Boot的自动配置依赖包扫描机制启动类所在包是所有组件扫描的基准。为了避开这类问题强制自己遵守一条规则——所有自定义的类包括配置类、拦截器、切面、工具类必须放到项目主包之下不要画蛇添足新建顶层目录。4.2 文件大小超过限制引起的异常处理另一个实际工作中很容易触发的问题是上传大图时报错。默认1MB的限制我用一台手机拍的照片随便就是3-5MB一传就报MaxUploadSizeExceededException。理论上来说我只要把配置文件里的max-file-size调大就行但这里有一个细节很多人容易忽略修改spring.servlet.multipart.max-file-size之后必须重启应用才能生效。开发环境下我习惯直接restart但如果是部署在生产环境需要确认是否使用了支持热加载的配置中心否则改了配置文件不起作用排查起问题来效率极低。异常信息返回给前端时的处理也同样重要。我在全局异常处理器里专门加了一个方法处理MaxUploadSizeExceededException返回“文件大小不能超过10MB”这样的中文提示。没有这一步的话前端拿到的是Spring默认的一大段英文堆栈信息用户根本看不懂。4.3 Windows与Linux路径差异带来的部署隐患开发机是Windows路径写法是D:/sky_upload/但部署到生产服务器的Linux环境时路径会变成/opt/sky/upload/这两个不同操作系统在路径分隔符上的处理方式也不一样。如果代码里写了File.separator在Windows下是\Linux下是/直接用new File(uploadPath / newFileName)这种写法在Windows下也能正常工作因为Java的File类会根据当前操作系统智能处理分隔符但如果你用的是手拼字符串访问路径就很容易出问题。最稳妥的办法是把路径统一配置在配置文件里不同环境的部署包使用各自环境对应的配置。我现在的做法是把sky.upload.path按环境拆到application-dev.yml和application-prod.yml启动时用-Dspring.profiles.active指定当前环境。以后接CI/CD流程时这一步只需要维护好不同profile的配置文件即可代码层不用做任何改动。4.4 中文文件名乱码与后缀遗漏问题如果前端传的文件名包含中文比如“土豆炖牛肉.jpg”直接使用原始文件名保存的话URL里的中文会被浏览器自动百分号编码虽然不影响访问但日志打印、文件管理、后续迁移都会造成很多不必要的麻烦。这也是为什么前面坚持不用原始文件名的原因。不过有一种情况值得注意有些图片文件本身没有后缀名或者用户上传的扩展名是JPG大写这时提取后缀的逻辑就要考虑容错把所有扩展名统一转成小写并且对没有后缀的文件补充一个默认后缀比如.jpg。我在Part 2.3的代码里已经做了基础判断实际生产中建议再加强一步用白名单校验后缀是否在允许范围内不在白名单内的直接拒绝这样能防止有人传一个.html之类的奇怪文件混进来。5. 当天开发的流程复盘与效率心得5.1 开发节奏从大模块到小细节的推进方式第三天一共推进了四个核心功能模块整体感受是节奏比前两天要紧张一些因为菜品管理牵扯到的表关系和接口逻辑更多。今天的推进方式基本沿用了一套自己摸索出来的组合拳先把Controller层的接口签名定下来再把Service层的接口定义好然后一鼓作气完成ServiceImpl的编码最后才是Mapper层SQL然后用Postman做接口自测。这套从外到内的开发顺序核心思想是先锁定接口边界把输入输出定义清楚再去填充底层的实现逻辑这样不会出现底层做完了接口配合不上的返工情况。写Mapper层SQL时尤其要谨慎因为SQL一旦写错报错信息虽然直接告诉你哪一行有问题但定位问题的过程还是会消耗大量时间。所以写复杂SQL时我会先在数据库客户端里跑一遍确认结果集正确后再粘贴到XML文件里并将问号参数替换成#{}占位符。这个习惯可以大幅度缩减联调时间避免把SQL语法问题和参数绑定问题混在一起排查。5.2 当天最有价值的一个细节DTO与VO分离的设计思考今天在写分页查询返回结果时我用了一个DishVO对象它有菜品表的所有字段还额外多了一个categoryName。很多同学在刚开始写代码时会直接在Mapper里return一个Map或者直接返回实体类Dish又或者在原有实体类上加一个不属于表的字段。这些方案在小项目里看起来没毛病但一旦实体类和表结构严格对应后续任何改动都可能引起不必要的麻烦。DishVO这个类的本质是“视图对象”它服务于前端展示层的需求和持久层的Dish实体彻底解耦。分类名只属于展示需求不属于菜品表本身所以专门弄一个VO来承接这才是分层架构里正确的处理方式。写Service层的转换代码时可以用Spring的BeanUtils.copyProperties方法一行代码完成同名属性的拷贝然后单独把categoryName的值赋给VO。这样写下来代码既简洁又清晰没有任何重复手工setter的噪音。5.3 后续演进方向从本地上传到云端存储的路径本地图片上传跑通后苍穹外卖项目往后肯定要面对云端存储的演进问题。生产环境中应用服务可能会被部署到多个服务器节点用户的图片请求如果落到了另一台节点上就无法读取到文件。这不行所以未来大概率要接OSS或者云存储服务。但今天这套本地存储实现其实已经把完整的策略接口抽象好了后续在UploadService接口下新增一个OssUploadServiceImpl实现类用云SDK替换本地的文件保存和URL构造逻辑再通过配置指定调用哪一个实现Controller和Service的调用层完全不用改。这就是设计模式的魅力定义好接口边界把变化封装在实现类内部系统就有了弹性扩展的能力。5.4 写给同样在开发苍穹外卖的同学几条建议最后写几条今天实操总结出来的经验送给正在照着这个项目练习的同行。第一条接图片上传之前先确认工程的WebMvcConfig能生效最简单的方法是用浏览器直接访问一张放在资源目录下的图片如果能访问就说明Spring MVC静态资源机制正常再继续做上传功能。第二条新增菜品联调时可以先不传口味数据只做基础字段的插入验证等基础通过后再补口味问题定位会更精准。第三条对不熟悉PageHelper的人来说分页查询报错时优先检查依赖是否引入完整很多NoClassDefFoundError都是因为只引入了PageHelper的核心包但缺少pagehelper-spring-boot-starter导致的。第四条每天开发结束前用git提交一次代码commit message写清楚今天完成了什么哪怕只有一句话对后期写项目总结和回溯排错都有极大帮助。今天这个第三天的开发从功能维度看是把图片上传、静态资源映射、分页查询、新增菜品这一整条链路走通了。从学习维度的收获更大文件存储策略的抽象思路、VO与实体类分离的思想、多环境配置的管理方式这些东西的价值不会局限在苍穹外卖这一个项目里。个人体会最深的一点本地图片上传看似是个不起眼的小功能但它串起来的Spring MVC静态资源机制、MultipartFile处理、配置化设计几乎是后端开发绕不开的基本功。今天把这些基础打扎实了后面做套餐管理、店铺状态管理、订单模块的时候会明显感觉到底气更足。明天计划推进菜品的修改和删除接口同时把Redis缓存用起来让菜品列表的查询性能和起售停售的状态控制更贴合生产环境的实际要求。
返回列表