资讯动态

苍穹外卖第三天:店铺状态缓存、菜品分页与本地图片上传实战

发布时间:2026/10/5 2:53:54 来源:尧图企业网站定制
1. 第三天开工前的状态盘点1.1 前两天都做了什么到今天为止苍穹外卖这个项目我已经跟进了两天。整体进度不算快但是每一步都踩得比较实。第一天主要把项目骨架搭起来了Spring Boot MyBatis的底层结构、统一返回结果类Result、全局异常处理器、一些基础的工具类比如JWT工具、ThreadLocal存储当前登录用户ID的工具类以及员工端和管理员端的登录鉴权逻辑。这个阶段看起来不起眼其实决定了后面所有功能模块的开发节奏。我记得当时光是统一返回结构就纠结了一会儿后来直接参考了市面上主流的做法code、msg、data三件套配合一个Result.success(...)静态方法Controller层代码写出一种清爽的感觉。第二天做的内容相对多一点完成了员工管理模块的员工分页查询、新增员工、修改员工、启用停用员工这几个接口顺带把公共字段自动填充的MyBatis拦截器也写了。拦截器这个东西很有意思它解决的是“谁在什么时间创建/修改了这条数据”的重复劳动问题在苍穹外卖这种管理后台里特别实用。两天下来项目已经有一个后台管理系统基本的呼吸感了。但其实我心里清楚前面的工作更偏向“通用能力”还没碰外卖业务真正有辨识度的部分。所以第三天开始我打算往业务侧走深一点店铺营业状态、菜品管理还有那个被很多人提起的“本地上传图片”功能。1.2 第三天的任务拆解第三天的任务其实很明确我在开工前拆成了四个小目标完成店铺营业状态的查询与修改接口这个功能直接对应外卖App端商家是否接单的状态展示完成菜品管理模块的基础部分分页查询、按分类筛选、新建菜品搞定本地上传图片功能让管理端可以上传菜品图片图片落到本地目录然后通过URL访问到对现有代码做一次小范围重构主要是把一些重复的DTO和VO转换操作统一处理一下。这里有个背景需要说一下我当时并没有用云存储服务来保存图片而是选择先把图片存到本地磁盘只在数据库里记录访问路径。这算是“苍穹外卖”这类项目日记里比较经典的一种做法因为我更看重先把链路走通后面再考虑迁移对象存储或者OSS。事实证明这个决定是对的。本地存储方案在开发和演示阶段足够用而且它会逼着你把静态资源映射、虚拟路径访问、文件命名防冲突这些事情都搞清楚这些本身都是通用的后端技能。2. 店铺营业状态一个看似简单却不能再犯糊涂的小功能2.1 Redis存储营业状态的思路店铺营业状态这个需求从接口层面看就两个动作查询当前状态、修改状态。但“状态存在哪里”这里有个讲究。我第一天在做框架的时候已经引入了Redis依赖。按理说这种状态数据也可以直接放数据库表里但营业状态最大的特点是读多写极少而且对实时性要求不高但必须有。每个人打开App都要拉一下店铺是否营业高频读取场景用Redis来做缓存非常合适。我在Redis中用了这样一个keyshop:status值为1表示营业0表示打烊。用一个常量类把key固定下来避免到处写死字符串。这里要特别提一下字符串散落在代码里是最隐晦的隐患后面如果想统一加个前缀或者调整命名你会发现几十个文件等着你慢慢改。为什么不直接用Spring Cache的Cacheable注解我也考虑过。但在苍穹外卖这种管理端用户端的双端结构里用户端的接口和商家端的接口需要共享同一个缓存维度直接用RedisTemplate手写读写反而更直观后续加入Spring Cache做更细粒度缓存的时候也不冲突。2.2 接口设计和代码落地接口设计上我遵循了Restful风格查询状态GET /shop/status修改状态PUT /shop/status/{status}这样设计的好处是语义清晰而且前端对接时接口数量也最少。Controller里的代码非常薄核心逻辑就在Service层。先看查询端RestController(userShopController) RequestMapping(/user/shop) public class UserShopController { Autowired private StringRedisTemplate stringRedisTemplate; GetMapping(/status) public ResultInteger getStatus() { String status stringRedisTemplate.opsForValue().get(STATUS_KEY); if (status null) { status 1; // 默认营业 } return Result.success(Integer.valueOf(status)); } }这里有个细节我用RestController(userShopController)给Bean指定了一个别名因为后面管理端可能还会有一个ShopController两个Controller如果类名重复Spring容器启动会因为Bean名称冲突直接报错。这种问题在开发中真的很常见提前规避掉能省不少事。修改端的代码就一行写入PutMapping(/status/{status}) public ResultString setStatus(PathVariable Integer status) { stringRedisTemplate.opsForValue().set(STATUS_KEY, status.toString()); return Result.success(); }别看代码这么简单实际上有一个坑我已经踩过了如果用Integer接收路径参数前端传0和1没问题万一有人传了个true或者yesSpring MVC类型转换直接抛异常然后被全局异常处理器吞掉返回一个操作失败。所以这块要么加强校验要么在全局异常处理里对类型转换异常做一个单独的提示让前端知道参数不对。还有一个容易被忽略的点营业状态的修改通常需要管理员权限。之前我写了JWT登录拦截器但那是校验“有没有登录”还没区分“有没有权限”。这块功能目前还没做细我先在Controller上留了一个注释等后面做权限管理的时候统一加。3. 菜品管理查询链路分页列表与分类关联3.1 三层结构怎么分层菜品管理比营业状态复杂了几个数量级因为菜品涉及分类、图片、状态、口味等多个维度。我在做这类功能时习惯先梳理数据流向再动手写代码。苍穹外卖的菜品功能里管理员打开菜品管理页面时期望看到的是一个分页列表每一行包含菜品名称、所属分类、价格、图片、售卖状态、最后操作时间。其中“所属分类”是个关键点数据库里菜品表存的是categoryId前端需要的是分类名称这就要做一次关联查询或者组装。我在Service层里采用了标准的三层划分Controller只做参数接收和结果返回Service负责业务逻辑和数据组装Mapper负责最基础的单表SQL操作。菜品分页查询的链路大致是这样的Controller层接收分页参数 - Service层调Mapper进行分页查询 - 拿到Page对象后遍历菜品列表 - 根据每个菜品的categoryId查出分类名称 - 组装成VO返回给前端这里的“遍历查出分类名称”听起来很笨如果菜品一多就会产生N1查询问题。我当前的实现先在数据量小的前提下保证逻辑清晰等后面N1问题成为瓶颈时再用SQL联查或者批量查询来优化。这是我个人比较喜欢的做法先保证正确和清晰再考虑性能。3.2 PageHelper分页的参数细节分页我用的PageHelper插件配置起来很简单但有几个细节必须注意。首先是分页参数传递前端传来page、pageSize还有可选的name、categoryId、status。这些参数我用一个DTO类接收而不是散落的多个参数。这样做的好处是如果后面要加排序字段、时间范围不需要改Controller的方法签名。然后是PageHelper的一个关键点分页拦截器会作用于紧随其后的第一条SQL查询。如果你在PageHelper.startPage(page, pageSize)之后调用了多个Mapper查询只有第一次查询会被分页。所以我养成了一个习惯startPage后面紧跟业务主查询其他辅助查询绝不插在中间。ServiceImpl里的核心代码大致是这样public PageResult pageQuery(DishPageQueryDTO dto) { PageHelper.startPage(dto.getPage(), dto.getPageSize()); PageDish page dishMapper.pageQuery(dto); ListDishVO records new ArrayList(); for (Dish dish : page.getResult()) { DishVO vo new DishVO(); BeanUtils.copyProperties(dish, vo); Category category categoryMapper.getById(dish.getCategoryId()); vo.setCategoryName(category null ? 未知分类 : category.getName()); records.add(vo); } return new PageResult(page.getTotal(), records); }这段代码里有用到Spring自带的BeanUtils.copyProperties这个工具可以自动把同名属性复制过去省去大量冗长的setter调用。但要注意源对象的属性名和目标对象的属性名必须一致否则会静默不复制而且它不会报错。我第一次用的时候因为Dish里有个categoryIdDishVO里也有categoryId没问题但如果VO里叫category_id那就是一场灾难这个问题在代码审查时很难发现。3.3 状态字段的显示转换菜品有一个status字段1表示起售0表示停售。数据库里用数字存前端页面展示时需要一个标签或者文字。我之前在其他项目里的做法是交给前端转换前端判断0和1分别显示什么样式。但苍穹外卖这个项目的前端是已经固定好的它直接使用后端VO里跟状态相关的一些字段。更关键的是前端需要一个“按钮权限”的隐式判断如果菜品起售中显示“停售”按钮如果停售中显示“起售”按钮。这个逻辑前端自己就能做不需要后端额外返回一个按钮文案。这时候后端的职责就是把状态值原样返回不要做多余转换。我在DishVO里保留了status字段避免过度设计。不过这里提醒一下如果未来要做批量操作比如批量起售、批量停售那Controller的入参就不要设计成一个DTO带一个ids集合而是用RequestBody ListLong直接接收ID数组这种接口语义更清晰也方便前端直接调用。4. 本地上传图片苍穹外卖项目里最容易翻车的模块4.1 为什么先用本地存储而不是OSS苍穹外卖的菜品管理肯定要涉及图片我问了一圈做过类似项目的朋友大多数人在这一步直接上了阿里云OSS或者七牛云但我最终还是先用本地存储。理由很实际这个阶段的目标是先把端到端流程跑通不引入外部依赖。如果直接上OSS需要注册账号、创建Bucket、配置AccessKey、引入SDK、写上传工具类这些工作会分散注意力而且一旦AccessKey泄露还可能有安全隐患。本地存储方案就不存在这些问题代码路径足够短出问题也只在两个地方文件路径和URL访问。另外本地存储并不只是“偷懒”的做法。我之后在项目复盘里发现正是因为我先把本地存储的完整链路打通了后面切换到OSS时思路非常顺畅因为上传接口的传输层协议没变变的只是“文件最终存到哪里”。4.2 上传接口的完整写法上传图片的需求是这样的前端页面通过表单提交一个multipart文件后端把这个文件保存到服务器指定目录然后返回一个可访问的URL给前端。前端把这个URL作为图片地址存到数据库里。Controller层代码PostMapping(/upload) public ResultString upload(MultipartFile file) { if (file null || file.isEmpty()) { throw new BusinessException(上传文件不能为空); } // 生成唯一文件名避免覆盖 String originalFilename file.getOriginalFilename(); String extension originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName UUID.randomUUID().toString() extension; // 保存到本地指定目录 File dir new File(basePath); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(dir, fileName)); } catch (IOException e) { throw new BusinessException(文件保存失败); } return Result.success(/images/ fileName); }这里有几个设计细节值得展开说。第一文件名必须用UUID重新生成。原因是用户上传的原始文件名千奇百怪可能有中文、空格、特殊符号两个用户还可能上传同名的文件。如果直接使用原始文件名总有一天会因为重名而覆盖掉之前的图片到时候菜品图片突然变成别的菜排查起来非常酸爽。第二原始文件的扩展名必须保留。虽然我用UUID重命名了主体部分但后缀名还是从原始文件名里截取的这个后缀决定了浏览器以什么Content-Type去渲染这张图片。如果丢了后缀浏览器可能直接当成二进制流下载而不是展示图片。第三file.transferTo()方法内部会处理文件流的关闭大部分情况下不需要手动关闭。但如果存储的目录跨了文件系统比如Spring Boot打包成jar运行时临时目录和实际目标目录不一样transferTo有可能会因为文件系统不一致而失败。所以更稳妥的做法是用Files.copy()配合StandardCopyOption.REPLACE_EXISTING这个在后续迁移OSS时会更有参考价值。4.3 静态资源映射与访问路径文件保存到了本地接下来的问题是什么路径能访问到它。我在上面返回的URL是/images/文件名但如果现在直接访问Spring Boot会返回404因为项目里并没有一个叫images的静态资源目录或者这个目录不在classpath下。我需要做一层虚拟路径映射把/images/**映射到磁盘上的实际目录。Spring Boot里实现方式很简单实现WebMvcConfigurer接口重写addResourceHandlers方法Configuration public class WebMvcConfiguration implements WebMvcConfigurer { Value(${sky.img.base-path}) private String basePath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/images/**) .addResourceLocations(file: basePath); } }这里我把basePath配置在了application.yml里而不是硬编码。配置的好处是不同环境下可以指向不同目录比如本地开发用/Users/me/data/upload/服务器上用/home/ubuntu/sky-take-out/upload/不需要改代码。配置内容再补全一下sky: img: base-path: /Users/me/data/upload/注意addResourceLocations的路径最后必须以/结尾而且要用file:协议前缀否则Spring无法正确解析为本地文件路径。我第一次漏掉了最后的斜杠结果浏览器访问图片一直在报404排查了半天才发现是路径拼接的问题请求/images/a.jpg会映射到file:/Users/me/data/uploada.jpg目录结构和文件名完全串位了。顺便说一句很多教程里写的上传和访问路径是同一个静态目录也就是把图片放在src/main/resources/static/images下面。我不建议在开发阶段这么干因为重新构建项目时会清空resources目录之前上传的图片可能会全部丢失这个坑遇到一次就记住了。4.4 三个必踩的坑整个本地上传图片功能开发下来我总结出了三个必踩的坑每一个都花了不少时间才定位到。第一个是文件大小限制问题。Spring Boot的Servlet容器默认限制了上传文件的大小大概是1MB到2MB左右。如果上传的图片超过这个限制Spring会直接抛异常而且前端收到的报错信息还不一定友好。我当时上传一张高清菜品图前端直接提示失败后端日志里看到MaxUploadSizeExceededException才明白怎么回事。解决方案是在配置文件中调大限制spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB如果是多文件上传max-request-size要设置得比单文件更大因为一次请求可能包含多个文件和其他表单字段。第二个是Linux服务器的目录权限问题。本地Windows或者Mac上开发时mkdirs()一般都能成功但在Linux服务器上如果指定的目录在某个用户权限范围之外File对象创建目录时可能失败但不一定会抛异常实际保存文件时才会报Permission denied。我后来在部署脚本里加了一步mkdir -p /home/ubuntu/sky-take-out/upload chmod 755 /home/ubuntu/sky-take-out/upload这样能保证目录存在且Web应用有写入权限避免了很多莫名其妙的问题。第三个是图片访问的跨域问题。如果前端页面跑在8080端口后端服务跑在8081端口浏览器直接访问/images/xxx.jpg会出现跨域。这个问题在苍穹外卖项目里尤其明显因为管理端页面和后端接口是两个不同的服务。解决方案是在后端的跨域配置里把静态资源的路径也放行或者在Nginx层面对图片路径做单独转发后者在正式部署时是更优雅的方案。5. 后续优化方向与个人记录5.1 本地存储切换OSS的思路虽然当前本地存储方案跑通了但我清楚它是有天花板的图片越来越多之后磁盘占用会膨胀应用进行多实例部署时每台机器的本地磁盘都是独立的会出现“A机器存的图片B机器上访问不到”的问题。所以从架构演进的角度切换到OSS或者云存储是迟早的事。如果后面做切换我的计划是这样的引入云存储SDK写一个FileStorageService接口本地存储实现和云存储实现分别放在两个类里上传接口里只依赖接口不依赖具体实现通过配置文件切换当前使用哪种存储模式云存储返回的URL直接是公网可访问的完整地址不再需要静态资源映射。这样改动范围会控制在很小的范围内Controller层几乎不用动。这也是当初我把上传逻辑收敛在Service层的原因接口和实现分离之后后续演进成本就低很多。5.2 今天代码之外的一点体会第三天的开发结束后我翻了翻今天的代码营业状态看似简单但Redis的key设计如果有讲究后面的缓存清理逻辑会省很多事菜品管理的分页查询看上去平淡无奇但N1问题和DTO/VO转换的规范直接影响维护体验本地上传图片是整个项目里最贴近真实业务的一环因为它涉及了文件IO、路径映射、异常处理、浏览器访问机制等多个层面的知识。做这类项目笔记我个人的体会是“宁可多写代码也不要跳步骤”。很多初学者喜欢一开始就追求高性能、高扩展性但实际开发中把一条链路用最简单的方式跑通比设计一堆用不上的抽象类要重要得多。苍穹外卖这个项目好就好在它的业务场景足够真实每个功能都能落到具体的页面和操作上学习时很容易获得正反馈。明天我打算把菜品模块的剩余部分做完编辑菜品、批量操作、口味管理然后开始处理套餐管理的逻辑。到时候如果遇到新的有意思的坑再继续整理出来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑