
1. 项目全貌定位从苍穹外卖看真实业务系统的开发逻辑做了这么多年Java全栈开发接手过不少从零到一的项目也带过不少实习生。我发现一个很有意思的现象很多人练手都在做XX管理系统CRUD写得飞起但一进公司接触到真正的业务项目就懵了——用户端、管理端、消息推送、订单状态流转、缓存一致性……这些东西学校里没人教自己闷头练也练不出来。苍穹外卖算是这两年Java后端项目里比较有代表性的一个它不是一个单纯的CRUD Demo而是一个完整的O2O外卖业务闭环系统。你在美团、饿了么上看到的那套流程——用户点餐、商家出餐、骑手配送、管理后台运营——这套系统几乎都覆盖了。项目名称里的苍穹听着挺玄乎其实就是项目代号核心价值在于它把一个真实的商业场景拆成了完整的软件工程问题让你在练手阶段就能接触到真实项目里才会遇到的业务复杂度。这套系统适合谁坦白说三类人最需要它第一类是刚学完Spring Boot全家桶、想找项目练手但没有好素材的同学。第二类是准备面试Java后端岗的求职者因为它的功能点几乎覆盖了面试官最爱问的几大场景JWT鉴权、Redis缓存、WebSocket实时通信、多表关联业务、文件上传、定时任务。第三类是想系统理解业务系统架构的开发者——哪怕你已经参加工作把这类项目过一遍也能帮你补齐一些平时接触不到的模块设计思路。本文我会从项目整体架构、核心功能模块、技术选型逻辑到开发中真实会遇到的问题逐一拆解。这不是一篇照着官方文档念的教程主要是我自己撸这类项目时的思考过程——每个功能为什么要这么设计、哪些坑提前知道了能少走弯路。如果你是打算跟着做或者拿来面试突击这篇尤其值得细读。 ## 2. 双端业务形态拆解为什么外卖系统必须拆成用户端和管理端刚开始自己动手设计一个外卖项目时最容易犯的错误是把功能一股脑全塞进同一个工程里。用户注册、商家接单、平台管理、骑手派单……全写在一套Controller里最后代码互相打架改一处崩三处。苍穹外卖的标准做法是拆成两个清晰的业务端用户端C端和管理端B端。为什么必须这么拆我实际开发后的理解是这两个端的业务目标、使用人群、交互模式完全不同硬合在一起是灾难。用户端面向普通消费者核心诉求是快、好看、好用。它承载的是天天空闲时点个奶茶、午饭时间叫个外卖的流量场景这意味着接口需要响应快、流程顺滑大部分数据比如菜品、分类可以被缓存起来反复读取。它通常以微信小程序为载体——这也是为什么很多外卖类项目都会对接微信小程序端因为用户不需要下载App用完即走符合外卖高频低时延的使用习惯。管理端面向门店工作人员和平台运营者核心诉求是稳定、清晰、数据准。这部分使用频率远低于用户端但对权限控制要求极高——老板能看营业额店长能改菜品员工只能接单这些角色必须分清楚。管理端还要承担店铺状态管理、菜品上下架、订单处理、数据统计这类重活一旦出错直接影响线下门店的正常经营。两者的技术实现路径也因此不同用户端的小程序通过HTTP调用后端API请求天然带有用户身份信息鉴权走JWT Token状态靠Redis维持管理端是Web管理后台走登录页、记住密码这类更传统的Web交互用的是另一套登录状态管理用户端不直接展示营业中/已打烊这种运营数据管理端则要实时感知店铺营业状态的变化所以苍穹外卖的工程结构从一开始就分成了独立的用户端接口模块和管理端接口模块共用一个核心Service层来处理实际业务逻辑。这样既保证了前后端分离的清晰性也让两端的业务逻辑可以在Service层复用——比如开发订单模块时用户提交订单和管理端查询订单共用一套订单核心逻辑只是暴露的接口参数和权限校验各有不同。这种拆分带来的好处在我后续扩展功能时体会特别深。比如给用户端加一个再来一单功能我只需要在用户端接口模块里加一个Controller方法复用已有的订单查询和价格计算相关Service方法完全不影响管理端。反过来管理端要加周维度的营业额对比图表时也只是往里加统计接口根本不用动用户端任何一行代码。3. 核心技术选型与分层架构Spring Boot之外的工程化思考苍穹外卖的技术栈翻译成大白话就是Java Spring Boot MyBatis Redis MySQL再加上WebSocket和定时任务这种常被忽略的特殊武器。3.1 技术选型背后的取舍逻辑很多同学在搭建项目时会陷入纠结用MyBatis还是JPA要不要上Spring CloudRedis到底该用在哪一层我的建议是别想复杂了。苍穹外卖这类项目选型有一条主线逻辑单体架构能解决的事不要过早引入微服务。Spring Boot 3 Java 17这是目前主流的生产级组合。Spring Boot 3的自动配置和Starter机制大幅压缩了项目初始化成本让你可以把精力集中在写业务代码上Spring MVC做RESTful API的标准选择无需解释MyBatis外卖业务的SQL复杂度不低——订单查询经常要连用户表、菜品表、地址表做多表联查MyBatis的XML里可以精细控制SQL语句出问题时也方便单独调优MySQL永远的核心数据存储存放用户、订单、菜品等结构化业务数据Redis用于缓存高频查询数据菜品、分类、维持登录令牌、处理抢单/秒杀场景下的库存扣减问题WebSocket实现订单状态变更时服务端主动向客户端推送消息Spring Task处理店铺营业状态的自动更新、订单超时自动取消这类定时任务JWT Spring Security / 拦截器做双端登录鉴权这套组合的核心思想是每引入一个组件都必须能在系统里找到它存在的明确理由。Redis不是摆在那里好看的WebSocket也不是为了显摆技术它们都对应了真实业务中实打实的功能痛点——这部分我后面会展开说。3.2 分层架构与模块划分苍穹外卖按标准的**Controller → Service → MapperDAO**三层架构来组织代码这是Spring生态里最经典也最保险的写法。Controller层只做参数接收、调用Service接口、结果封装返回不写业务逻辑Service层业务核心。订单流程控制、缓存读写、状态流转这类规则全在这里Mapper层纯粹的数据访问。每条SQL精确控制配合MyBatis的动态SQL处理复杂查询条件我实际开发时会把每一层的职责边界划得很清楚这条红线会影响你后面所有功能的迭代效率。一个最常见的反面教材是有人图省事直接在Controller里写一堆SQL相关的逻辑结果后期加一个日志需求改动就蔓延到五六个文件。分层不是为了好看是让你在改某一个功能时只动该动的那一层。3.3 公共模块设计冗余代码集中消灭项目里有多少个接口需要返回统一格式的数据用户登录要返回Token订单查询要返回订单列表菜品浏览要返回菜品集合……如果没有统一封装每个Controller自己拼JSON结构调用方前端小程序就得为每个接口写不同的解析逻辑那是纯粹的灾难。苍穹外卖的处理方式是定义一个公共Result类统一封装响应状态码、提示信息和数据体让每一个Controller方法都返回这个类型Data public class ResultT { private Integer code; // 1成功0失败 private String msg; // 提示信息 private T data; // 实际返回数据 public static T ResultT success() { ResultT result new Result(); result.code 1; return result; } }这样的好处不用我多解释。前端只需要针对Result结构写一套解析逻辑所有接口都能直接对接。类似的公共模块还有分页查询结果的封装、常量类比如订单状态的数值定义、工具类Token解析、日期处理。3.4 配置文件与多环境切换配置管理是项目里最容易被轻视、但出问题最折磨人的部分。苍穹外卖里至少要面对三种环境本地开发环境、测试环境、生产环境。每套环境的数据库连接、Redis地址、小程序AppID甚至日志级别都不一样。Spring Boot提供的多Profile配置机制正好解决这个问题# application-dev.yml spring: datasource: url: jdbc:mysql://localhost:3306/sky_take_out username: root password: 123456 # application-prod.yml spring: datasource: url: jdbc:mysql://生产服务器IP:3306/sky_take_out username: sky_prod password: 加密后的密码启动时通过--spring.profiles.activedev指定走哪套配置。我特别想提醒的一点是密码等敏感信息不要明文写进配置文件里提交到Git仓库。后面工作中公司一般会要求接入配置中心或环境变量注入练手项目里也至少养成把生产配置排除出提交范围的习惯。4. 用户端核心链路从菜品浏览到订单状态实时推送用户端是整个外卖系统里最刁钻的部分它承载的流量最重用户耐心又最少。菜品种类和店铺状态都适合放到缓存里——反正不怎么变何必每次都去查数据库4.1 缓存策略把读多写少发挥到极致用户端小程序一打开第一件事就是请求店铺的营业状态和菜品分类数据。这类数据的特点是读的频率极高写的频率极低。一天里可能被访问几万次但实际修改可能只有打烊切换、菜品上新那几次。苍穹外卖的经典做法是把它们预载到Redis缓存中提前设计好缓存结构。以店铺营业状态为例它其实就是一个开关营业中/已打烊。Redis里我直接用一个String类型的key存储状态值# Redis中存储店铺状态 SET shop:status 1 # 1代表营业中 GET shop:status # 查询时直接读缓存不用碰数据库菜品分类数据就更典型了按分类维度存JSON前端一进来直接把整棵分类树拉回去每个分类热销、主食、饮品对应一个Redis的List或String结构按分类ID做key缓存数据在管理端修改菜品后同步失效或更新流程后面细说这样处理的直接好处是用户在高峰期浏览菜单时后端服务几乎没有MySQL查询压力。每次菜品浏览请求走的是Redis这种纯内存操作QPS能轻松扛到几千甚至上万。实测下来一条浏览接口的响应时间从80ms降到了个位数毫秒用户端滑菜单的手感完全是两个级别。4.2 正确理解缓存穿透、击穿、雪崩缓存这套东西光知道把数据放Redis里是不够的。真实场景下Redis用得不好反而会带来麻烦——面试官也特别爱顺着这个话题往下追问。缓存穿透用户恶意请求一个根本不存在的数据ID比如菜品ID为999999缓存和数据库里都没有每次请求都会直接打到数据库。解决方案是缓存空对象即使是null也缓存一小段时间或者用布隆过滤器在请求进入前先拦截掉不存在的ID。缓存击穿某个热点key比如爆款菜品的分类列表在缓存过期的一瞬间同时涌来大量请求全部穿透到数据库。解决思路简单粗暴热点key不要设置过期时间或在旧缓存失效前使用互斥锁重建缓存。缓存雪崩大量key在同一时间集体过期请求整体穿透到数据库能把MySQL直接打挂。对策是给过期时间加上随机离散值避免集中失效// 给缓存设置随机过期时间避免雪崩 // 基础过期时间5分钟 加上随机数最多1分钟 long randomExpire 5 * 60 new Random().nextInt(60); redisTemplate.opsForValue().set(key, value, randomExpire, TimeUnit.SECONDS);我在实际项目中经历过一次缓存雪崩的现场凌晨有个定时任务批量更新了大量菜品缓存因为设置的过期时间一样凌晨流量低没感觉第二天中午高峰时所有菜品分类key同时过期数据库瞬时请求量暴涨接口集体超时。后来改成随机过期时间同样的情况再没出现过。4.3 订单状态流转设计允许用户干哪些事全看状态机订单模块是苍穹外卖里最核心也最容易写崩的部分。一个订单要经历的状态待付款 → 待接单 → 待送达 → 已完成中间还可能被取消或拒单。每个状态能执行的用户/商家操作是不同的这就是一个典型的状态机。我初期做订单模块时差点把所有状态判断都写在Controller里各种if嵌套代码很快就臭了。后面重构时理清了一个状态流转表整个逻辑一下就清晰了当前状态允许操作目标状态涉及角色待付款取消订单已取消用户待付款支付成功待接单用户系统待接单商家接单待送达管理端待接单商家拒单已取消管理端待送达用户确认送达已完成用户待送达系统自动确认已完成系统定时任务有了这张状态表我在Service层写订单状态更新逻辑时就只维护状态机而不需要在每一处都重新梳理业务规则。异常情况的处理也一样变得简单可控用户试图对已完成订单发起取消直接抛业务异常提示订单当前状态不支持该操作。4.4 WebSocket实时推送用户端丝滑体验的关键外卖场景里最影响用户感知的环节是什么是下单之后的状态反馈。你点完餐系统通知你商家已接单“骑手正在配送”——这些消息如果靠用户自己不停刷新页面去轮询体验会非常差而且在高峰期并发刷新也会给服务端带来压力。苍穹外卖用WebSocket来实现服务端推送。原理很简单HTTP是用户请求一次、服务器返回一次而WebSocket是客户端和服务端建立一个长连接之后服务端可以随时主动给客户端发消息。在小程序发起连接、确认身份之后订单状态的任何变化都能通过这条通道实时推送到用户屏幕上。后端核心代码大致是这样一个环节Component public class WebSocketServer { // ConcurrentHashMap保存当前在线的会话 private static final MapString, Session SESSION_MAP new ConcurrentHashMap(); /** * 连接建立后触发 */ OnOpen public void onOpen(Session session) { // 从请求参数中获取当前用户标识 // 将session存入SESSION_MAP } /** * 向指定用户推送消息 */ public void sendToUser(Long userId, String message) { Session session SESSION_MAP.get(String.valueOf(userId)); if (session ! null session.isOpen()) { session.getBasicRemote().sendText(message); } // 用户不在线时忽略或记录日志等待下次查询拉取 } }当订单状态发生变更时比如用户支付完成、商家点击接单Service层在更新数据库之后紧接着调用WebSocket的推送方法把最新状态发回用户端小程序// 商家点击接单后 orderService.acceptOrder(orderId); // 推送消息给用户小程序 webSocketServer.sendToUser(order.getUserId(), 您的订单已接单骑手正在赶往商家);这里有一个特别关键的设计考量推送不是数据一致性的保证而只是体验增强。万一WebSocket连接中断或者用户当时正好不在线推送消息丢失了订单数据本身并没有任何问题——只要客户端下次主动查询时能拿到正确的最新状态就行。所以推送方案和数据库更新必须解耦绝不能把推送结果当作业务成功与否的判断依据。我在项目里会同时保留一个按订单号查询详情的接口作为兜底保证任何情况下用户都能拿到准确的订单状态。5. 管理端核心链路菜品管理、权限体系与用户端数据同步管理端看着像个中规中矩的后台系统但真正把它做好——尤其是和用户端共用同一套数据模型时——会有一些只在业务系统里才会遇到的麻烦。5.1 菜品分类与菜品的树形关系菜品的核心数据结构分两层分类热销、主食、饮品和具体菜品宫保鸡丁属于热销分类。这个结构看似简单但细节在于分类和菜品都有启用/停用状态而且这两个状态会相互作用。一个分类下面有10道菜其中3道停售了如果运营人员把这个分类整个停用那下面的菜品要不要跟着变苍穹外卖的常规做法是分类状态独立管理但用户端在查询时以分类启用状态为前置筛选条件。也就是说即使分类下某些菜品是停售状态分类本身停用后这个分类在用户端就不展示了用户自然也就看不到分类下的菜品。这样管理端在操作时逻辑清晰用户端也不会出现分类没了但菜还在的怪象。5.2 文件上传与图片展示联动外卖系统的菜品图片从哪里来管理端上传用户端展示。文件上传功能看起来是标配但苍穹外卖里有一个必须处理好的问题上传后的图片URL如何和菜品数据联动。我用的方案是管理端调用统一的文件上传接口把图片文件通常是multipart/form-data格式传到服务端服务端保存后返回一个可访问的URL地址前端把URL作为菜品图片字段提交给后端保存。这个URL的生成逻辑在开发和生产环境还不一样——本地开发时图片文件保存在本地磁盘访问地址是http://localhost:8080/images/xxx.jpg部署到服务器后则需要换成可公网访问的路径。这里有一个很容易踩的坑上传图片保存在本地磁盘只适合开发阶段。项目一旦部署到服务器或使用容器化环境本地文件系统就变得不可靠——重启就丢、扩容就找不到。工作中更普遍的做法是接入对象存储OSS把图片文件直接传到云端数据库只存一个返回的URL。练手阶段用本地存储问题不大但脑子里一定要有这个升级意识。5.3 管理端权限控制与登录状态管理管理端比用户端多一个维度要管角色权限。不同岗位能做的事情不一样——店长能设置营业状态、上架下架菜品普通员工可能只能接单、查看订单。如果所有管理端用户都拥有全部权限线下门店的经营风险非常大。权限控制的工程实现就是拦截器 Token解析的经典组合。管理端登录后服务端签发一个JWT Token登录者后续每次请求都带上这个Token拦截器在请求进入Controller之前解析Token——如果是有效Token放行Token过期或非法直接返回401状态码让前端跳回登录页。Component public class JwtTokenAdminInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(token); // 校验Token合法性、是否过期 // 解析出当前登录员工ID存入ThreadLocal上下文 return true; // 校验通过则放行 } }体现权限差异化操作的方法也很直接不同角色用户登录后能调用的接口集合不同。比如营业额统计接口只对店长管理员开放普通员工调用会返回无权限错误。这是通过接口级拦截 角色判断实现的权限粒度按实际操作需求设定既不做成拍脑袋的全放行也不做成连查个订单都要审批的臃肿模型。管理端更新数据后怎么让用户端立即生效这就是我前面说的缓存同步问题。管理端点击上架菜品或停售分类后用户端如果还读到旧缓存用户看到的菜单就是错的。处理方案并不复杂管理端在数据变更操作完成后主动删除对应的Redis缓存key。用户端下一次请求时发现缓存miss从数据库加载最新数据并回填缓存数据自然就同步了。// 更新菜品状态后删除对应分类的缓存 PostMapping(/status/{status}) public ResultString updateStatus(PathVariable Integer status, Long id) { // 更新数据库中的菜品状态 dishService.updateStatus(status, id); // 清理缓存让下次请求重新加载 String cacheKey dish:category: categoryId; redisTemplate.delete(cacheKey); return Result.success(); }这套更新数据库 → 删缓存的模式是最简单可靠的一致性方案也是绝大多数中小型业务系统通用的做法。我见过有些同学喜欢用先更新缓存的方式结果缓存和数据库永远对不上线上直接出事故。记住一句话缓存可以丢数据库不能错。所有以数据库为准、缓存只做加速的做法基本上都是安全的选择。5.4 营业状态自动切换与定时任务管理端有个设置营业状态的按钮营业中/已打烊就一个开关。但真实外卖场景里店铺应该是一天里固定时间段营业的——比如10:00到22:00开门接单其他时间自动打烊。全靠店长手动切总有忘记的时候。苍穹外卖就用Spring Task定时任务解决了这个需求。定一个每分钟执行一次的定时任务检查当前时间Component public class ShopStatusTask { // 每分钟执行一次 Scheduled(cron 0 * * * * ?) public void autoUpdateShopStatus() { LocalDateTime now LocalDateTime.now(); Integer openHour 10; // 营业开始时间 Integer closeHour 22; // 营业结束时间 boolean shouldOpen now.getHour() openHour now.getHour() closeHour; // 根据当前时间更新Redis中的营业状态 } }这里有一个很多人会忽略的问题定时任务和缓存状态的一致性。营业状态是存在Redis里的定时任务更新Redis里的值看起来没问题。但如果服务器时间和真实时间有偏差、或者时区配置错了自动开关店时间就会乱掉。我建议所有时间相关逻辑统一使用服务器标准时区并且用一个静态配置类来管理开店时间打烊时间这些业务参数避免在代码里散落一堆魔数。6. 苍穹外卖实战开发中的典型问题排查与工程经验最后聊几个我开发类似项目时反复踩、也看别人反复踩的坑。这些问题面试会被问实际开发也会遇到提前知道了能少走不少弯路。6.1 缓存一致性问题的完整排查链路有一段时间线上线下反馈管理端把某道菜下架了用户端小程序端还是能刷到这道菜甚至下单成功后商家端能看到这个菜品的单子。排查链路是这样的我先在测试环境复现管理端修改菜品状态为停售马上刷新用户端菜单——菜还在列表里。确认问题存在。查代码发现管理端更新菜品状态后确实执行了删除Redis缓存的逻辑。那为什么用户端还读到旧数据查日志发现一个问题管理端删除缓存用的key和用户端查询时用的key根本不是同一个。管理端删的是dish:category:{categoryId}用户端查询时用的是category:list的整个分类列表缓存。两边key不一致自然删了个寂寞。统一key规范后问题解决所有涉及菜品变化的操作删除所有相关缓存维度。这个经历让我养成了一个习惯项目里所有和Redis相关的缓存key一定集中放在一个常量类里统一管理不写死在我们容易忽略的地方。6.2 并发下单场景下库存扣减的防超卖设计外卖系统和电商有一点本质区别外卖菜品的库存其实是预估库存而不是真实库存。但扣减逻辑的设计思路是相通的——高并发场景下不能先查询再判断再扣减必须把判断和扣减做成原子操作。我用Redis的事务命令来实现这个逻辑// 菜品库存 - 扣减库存以Redis为例 String script if redis.call(get, KEYS[1]) - ARGV[1] 0 then redis.call(decrby, KEYS[1], ARGV[1]) return 1 else return 0 end;这段Lua脚本把检查库存是否充足和扣减库存两个操作合在一起执行Redis保证脚本是原子执行的高并发请求同时进来也不会出现超卖。6.3 支付回调与订单状态的幂等性处理接入模拟支付/真实支付后必然会遇到一个问题支付回调可能被重复通知。微信支付的回调协议里明确说明商户必须处理重复通知的情况——如果用户支付成功后网络抖动回调错过了微信会以一定时间间隔多次推送同一笔支付结果。如果你在回调里直接写update order set status 3 where id ?重复回调时订单状态可能被覆盖回错误状态。正确做法是在回调处理逻辑里加一层幂等判断// 支付回调处理 public void handlePayCallback(String orderNumber) { // 先检查订单当前状态如果已经是待接单已支付说明已经处理过 Order order orderMapper.getByOrderNumber(orderNumber); if (OrderStatus.PAID.equals(order.getStatus())) { log.info(重复支付回调忽略订单号{}, orderNumber); return; } // 正常处理支付结果更新订单状态 }6.4 WebSocket连接管理与多实例部署的拓扑变化开发时WebSocket功能在本地单机跑得好好的但部署到生产环境时如果是多实例部署两台服务器同时跑同一个服务问题就来了用户小程序的WebSocket连接打到了服务器A但订单状态变更的操作执行在服务器B上——服务器B根本不知道服务器A上有这个连接。解决这个问题工作中常用的方案是把WebSocket会话信息从JVM内存中抽出来存到Redis的共享层用户连接WebSocket时把用户ID和连接实例的对应关系登记到Redis订单状态变更时先查Redis找到用户连接对应的实例编号如果属于本实例直接推送如果不属于把推送消息转发给对应实例。苍穹外卖这种单体项目的规模下用单机部署加本地SESSION_MAP是完全可以接受的但作为开发者你要知道这个设计存在扩展瓶颈面试聊到这个功能有什么可以优化的点时能说出这条演进路径就很加分。6.5 事务边界与异常处理的常见问题外卖系统里最典型的事务场景是用户下单。一次性要写入订单表、订单明细表、可能还要扣优惠券、清购物车。任何一个环节出错都不允许出现订单主表写成功了、明细表没写进去这种半成功状态。Spring Boot的Transactional注解很容易用但它有几个导致事务静默失效的经典场景我见过太多人踩过第一方法自调用。同一个类里methodA()调用了methodB()methodB上加Transactional是不生效的。因为Spring事务是通过代理对象实现的自调用绕过了代理。解决方法是把事务方法拆分到另一个Service类里或者通过注入自身代理来调用。第二异常被吞掉。事务方法里写了try...catch把异常捕获了事务判定没有异常就不回滚。原意可能是想记录日志结果数据和预期对不上Transactional public void createOrder(OrderDTO dto) { try { // 插入订单 // 插入订单明细 } catch (Exception e) { log.error(下单失败, e); // 什么都没做异常被捕获掉了事务照样提交 // 结果是订单主表写入了明细表没写入 } }正确做法是catch住异常后调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();标记回滚或者干脆把异常重新抛出去由外层统一处理。6.6 用户端再来一单功能里的隐藏状态校验最后分享一个模拟真实业务时经常被忽略的细节。苍穹外卖的用户端有个再来一单功能听上去就是把之前的订单菜品重新下一次单。但有一个业务坑原订单里的商品可能在用户再次下单时已经停售了。如果用户第一次下单点的是红烧狮子头三天后这个菜下架了用户点再来一单时服务端如果直接把原订单的商品清单带入新订单就会出现一道停售菜品被重新下单的bug。正确做法是在创建订单前重新校验每个商品的当前状态发现停售商品要明确提示用户部分商品已停售请重新选择或者直接过滤掉停售商品再下单。这种细节是真实业务系统里非常常见的边界情况也是那种你不在真实项目里踩一次就永远不会意识到的地方。7. 复现项目的建议与经验心得如果你打算自己动手把苍穹外卖完整做一遍我的建议是严格按照这样的节奏来推进不要跳步。第一阶段先把用户端跑通。搭建工程骨架实现登录鉴权、分类浏览、菜品查询、购物车、下单这几个核心链路。这一阶段的目标是让你把Spring Boot MyBatis Redis的组合用顺知道每个组件在业务里承担什么职责。第二阶段再做管理端。重点处理菜品管理、分类管理、营业状态设置、订单处理。这一阶段的核心收获是理解管理端操作如何反向影响用户端数据展示体会缓存同步和状态管理的设计思路。第三阶段补足关键的边缘能力。WebSocket推送、定时任务、支付回调处理、文件上传。这些功能单独看都不难但组合在一起就是完整外卖业务的真实面貌。时间上建议集中投入三到四周。不要把战线拉太长否则前面的知识忘了又得重来。最后说两句个人实操感受。苍穹外卖这类全栈项目最难得的地方不是某一项技术有多深而是它逼着你把学过的零散知识点放进一个完整业务场景里去检验。你会在里面第一次理解为什么需要Redis、为什么需要分状态来处理订单、为什么管理端和用户端不能共用一个Cookie、为什么改个状态这种小功能会引发连锁反应。如果你是为了面试准备这个项目我的建议是不满足于能跑而是对每个模块都多问一个为什么这样设计。能在面试时把自己的设计决策、踩过的坑、以及优化思路说清楚这个项目的价值就真正发挥出来了。