
2. 复盘苍穹外卖知识点第一期做后端开发的几乎没人不知道苍穹外卖这个实战项目。我当年刷完它之后最大的感受不是“我会写接口了”而是终于明白了“一个真实的外卖系统到底是怎么从零到一被拆出来、做出来、然后扛住线上压力的”。最近有不少同学问我这个项目值不值得刷、该怎么刷所以我打算出几期复盘把里面那些“文档里不会写透”的知识点掰开了聊一聊。先说结论这个项目最核心的四个关键字是管理端、用户端、订单流转、支付对接。这四个点基本涵盖了Java后端面试里百分之八十的高频考点从基础的MVC分层、MyBatis-Plus操作到稍微进阶一点的Redis缓存、Spring Cache、WebSocket再到真正能拉开差距的Spring Task定时任务、微信支付流程项目里都有涉及。这一期我们先从宏观架构和数据库设计讲起慢慢往里钻。1. 内容整体设计与思路拆解1.1 为什么每个外卖项目都长这样如果你手头有苍穹外卖的源码打开之后第一反应大概率是分模块太多了。admin端、user端、common公共模块三个大模块堆在一起刚开始很容易懵。但你别怕这种结构恰恰是真实企业项目的缩影。你可以把这三层理解成一个餐厅的运转逻辑common模块相当于餐厅的“后厨标准操作规程”所有菜怎么做、配料怎么切标准都在这里admin模块是“店长后台”管菜单、管员工、管营业额user端是“顾客点餐小程序”用户能看到什么菜、能下单、能催单。三者之间的依赖关系也很清晰user依赖commonadmin也依赖common但admin和user互不依赖。这种设计能带来一个很实际的好处——改小程序逻辑的时候绝不可能影响到管理后台反之亦然。如果你在一个工程里写过那种“五百个类堆在一起”的代码再回头看你就会特别感激这种拆法。1.2 从零设计外卖系统先解决哪些问题如果你要自己从零做一个外卖项目通常面临的是下面这几个问题用户身份怎么区分、菜品怎么分类上架、购物车怎么做、订单状态怎么流转、超时未支付怎么处理。苍穹外卖面对这些问题的解法在业界其实是比较标准的方案业务问题设计方案关键点用户身份JWT登录、双端Token校验管理端与用户端各自持有一套拦截器与登录逻辑菜品展示分类表与菜品表分离采用逻辑外键关联方便后续扩展口味、标签、起售停售状态购物车用户维度持久化到数据库不是Redis临时缓存避免丢数据引发客诉订单流转状态机机制待付款 → 待接单 → 待送达 → 已完成各状态之间有明确流转条件超时订单Spring Task定时扫描对待付款订单做超时取消防止库存与运力被无效占用这些方案单拎出来看都是常规操作但把它们放在同一个业务闭环里协同工作你才能体会到“设计一个系统”和“写一个接口”的差别。2. 核心细节解析与实操要点2.1 数据库设计先从两张“不起眼”的表说起很多同学刷项目时最容易忽略的就是建表脚本。因为代码能跑他就不去关心表结构为什么这么建。但这恰恰是苍穹外卖里最值钱的部分之一。以菜品表dish和口味表dish_flavor为例。你去看单表结构dish表里只存了菜品的基本信息菜品名、分类id、价格、图片、描述、状态、创建时间、创建人等。而口味表单独存了菜品对应口味名字、值、菜品id。为什么要拆两张表因为一道菜可能又加辣又去冰又加葱这些多值属性如果塞在dish表里要么搞出逗号分隔的一列要么就得冗余好几列。拆一张子表用外键关联查询时再join回来这才是关系型数据库的正确姿势。我自己踩过一个坑之前做项目图省事把所有口味都塞到一个字段里用JSON字符串存储。前期数据量小倒没啥感觉等用户量上来想要“按口味筛选菜品”的时候才发现字符串里的内容根本没有办法走索引只能全表扫描。后来老老实实拆表筛选查询的速度马上就提上来了。这里有个细节值得注意实体类里对时间字段的处理。苍穹外卖的公共字段创建时间、修改时间、创建人、修改人是通过MyBatis-Plus的字段填充自动处理的。你在插入或更新数据时不需要手动set这些字段只需要在对应的实体属性上加上TableField(fill FieldFill.INSERT)这类注解。这个设计很成熟能避免大量重复代码而且能保证每一条数据都有完整的审计信息。2.2 JWT双端登录一个系统里藏着两套令牌体系苍穹外卖里最绕不开的一个知识点是JWT。光会调接口还不够你必须清楚这两套Token为什么要分开。管理端的用户是员工对应的是employee表用户端的用户是C端顾客对应的是user表。两端登录接口各自签发各自的Token在网关层拦截的时候也各自校验各自的密钥。我见过不少人在简历里写“熟悉JWT”问他如果管理端员工Token泄露了用户端能用吗他答不上来。答案很简单不能。因为JWT的载荷里包含了身份标识和权限范围而接收方在验签时会先确认这是不是自己签发的Token。两端密钥独立就意味着即使某一端的密钥泄露另一端也不会立即失控。在自测时候有个非常实用的工具是Swagger。苍穹外卖里管理端的Controller都加了接口文档注解你本地跑起来之后可以直接在Swagger页面上做登录把返回的Token复制出来然后在每个请求的Header里带上它。只要Token没过期你就能在浏览器里直接模拟所有管理端操作。这种方式调起Bug来比Postman一个个手填要快得多。3. 实操过程与核心环节实现3.1 菜品管理模块从新增到起售停售的链路菜品管理这个模块是苍穹外卖里最适合用来打通“前后端分离开发思维”的案例。先看新增菜品的请求参数结构{ name: 宫保鸡丁, categoryId: 12, price: 28.00, image: https://xxx.com/1.jpg, description: 微辣花生米脆香, flavors: [ { name: 辣度, value: [微辣, 中辣, 特辣] }, { name: 忌口, value: [不要葱, 不要蒜] } ] }注意这个参数结构里面已经隐含了一个重要信息接收对象不能直接用dish实体做入参。因为dish实体里没有口味列表这个字段。所以苍穹外卖里专门定义了一个DishDTO它不但包含dish实体的所有字段还在内部放了一个ListDishFlavor用来接收前端传过来的口味数组。这一层封装不是多余的它解决的是前端传参和数据库结构不完全一致的问题。在实际企业开发中这种DTO对象你几乎天天都要写。很多人忽略DTO与实体分离的意义直接把前端的参数怼到实体上遇到“实体字段比参数少”或者“参数需要几个实体拼接”的时候代码就开始变形。而DTO能让你把接口入参的口径固定下来哪怕后端实体重构了只要DTO不变前端就不需要跟着动。新增菜品的完整链路是Controller接收DTOService层手动把DTO中的基础字段拷贝到dish实体调用dishMapper插入菜品基本信息遍历flavors列表逐个插入dish_flavor表事务控制任何一步失败则整体回滚。这里最容易被忽视的是事务。可能有人觉得插入两张表很平常但如果不用事务在执行过程中某一条insert语句报错就会留下“菜品存在但口味没有”的脏数据。脏数据在管理端会造成菜品编辑时口味为空在用户端会引发查不到完整菜品的尴尬。苍穹外卖里在Service层的add方法上加Transactional就是为了避免这种不一致。再聊一下“起售停售”这个功能。别看它只是一个开关底层逻辑其实涉及缓存一致性。菜品起售时要把状态改成1停售时改成0。但如果用户端使用了Redis缓存菜品列表这里就必须联动清理缓存。苍穹外卖的处理方式是调用Spring Cache的CacheEvict在修改菜品状态时把对应分类下的缓存数据淘汰掉。这么做有个好处是下次用户端请求过来缓存为空查询数据库生成新缓存展示出来的就是最新状态代价是首次请求会略微变慢但相比起用户端每次都被旧数据坑到这个代价完全可接受。3.2 购物车为什么存数据库而不是Redis购物车这个功能看起来简单但设计思路很有讲究。苍穹外卖选择把购物车绑定到用户id上单独建一张shopping_cart表。每一行记录用户加入的一个商品、数量、口味信息。之前有人问我“购物车这种高频操作为什么不用Redis缓存频繁写库不是浪费性能吗”我当时给他打了个比方购物车就像你去超市购物手里推的购物车你肯定不希望超市因为系统故障把你购物车里的东西清空。对于外卖场景也一样用户花了十分钟慢慢挑好的菜结果因为缓存过期就不见了这种体验是致命的。所以购物车必须持久化用户下次登录还能看到这才符合业务预期。这里有一个非常隐蔽的坑用户往购物车里多次加同一个商品时是插入新记录还是增加数量苍穹外卖的做法是先按userId dishId 口味查询如果已经存在就更新数量加一如果不存在才插入新纪录。这段代码的逻辑虽然简单但它体现了一个原则购物车的唯一性由“用户商品具体口味”共同决定。同一个宫保鸡丁一份不要葱和一份不要蒜其实是两条购物车记录。因为数据库里加入了购物车表所以支付模块取消订单时也可以反过来回滚购物车状态。这种数据闭环靠纯缓存方案很难做完整。3.3 订单状态流转与超时取消Spring Task 的真正价值订单模块是苍穹外卖里技术密度最高的部分。一张订单表里包含了订单号、下单人、下单时间、支付状态、订单状态、预计送达时间、菜品详情快照、地址快照等字段。对你没看错里面存的是“快照”。这里解释一下“快照”的概念。比如用户下单时选的宫保鸡丁是28元但商家在用户下单后把价格调成了35元。那这笔订单应该按哪个价格结算答案必须是下单时的28元。所以订单表不能只存菜品id它还必须在生成订单那一刻把菜品的名称、价格、图片全部冗余拷贝过来。这就是快照。这个设计可能在学校项目里不多见但在真实的电商系统里极其常见——它保护的是交易契约的稳定性。订单状态流转的核心逻辑都集中在OrderServiceImpl里。常见状态包括待付款1待接单2已接单/配送中3已完成4已取消5退款6每个状态只能通过特定动作转换比如用户支付后从待付款变待接单商家接单后变配送中送达后自动完成。如果你在写状态更新时不加条件限制直接set一个新状态那么客户端只要多传一个状态值订单就能被恶意跳过流程。所以苍穹外卖里的状态更新基本都是UPDATE orders SET status ? WHERE id ? AND status ?这种乐观锁风格的条件更新这也是应付面试时一个很值得说的细节。超时未支付的处理则是Spring Task在项目里最直接的落地场景。每过一定时间系统自动执行一次扫描任务把当前时间减去下单时间超过15分钟且状态仍为“待付款”的订单批量改成“已取消”。这种定时任务的成本很低也不追求实时性用Spring自带的调度机制就够用完全不需要上消息队列。这里还牵扯到一个“库存恢复”的隐藏逻辑用户下单时锁定的库存在订单取消之后必须释放。虽然苍穹外卖里商品库存的概念没有电商项目那么重但这个思路是一致的——取消订单不只是改一个状态背后关联的资源释放动作也得跟上。3.4 微信支付模拟没有真实商户号怎么联调这是很多同学卡住的地方。因为微信支付需要真实的商户号、证书和回调域名本地开发环境根本没法完成完整流程。苍穹外卖这里做了一个很务实的折中支付功能只需要生成支付参数真正发起支付时跳到模拟页面并把支付结果手动置为成功。但在知识点复盘时我们依然要理解微信支付的完整时序用户下单后后端调用微信支付的下单接口传入订单号、金额、回调地址微信返回一个支付链接或支付参数前端拉起支付用户支付完成后微信服务器异步通知后端回调接口后端校验签名、金额订单号确认无误后修改订单状态为已支付并向微信返回应答。本地没有真实商户号时调试重点放到第5步的回调逻辑。你可以用内网穿透工具把本地服务暴露到公网再用第三方支付测试工具模拟微信的异步通知。只要你能正确解析回调里的XML并完成验签这笔订单就算通了。等以后真拿到商户号你只需要换成真实的HTTP请求调用业务逻辑几乎不用动。4. 常见问题与排查技巧实录4.1 JWT拦截器放行了不该放行的路径这是所有人都会遇到的第一个问题。明明配了拦截器但某些匿名接口比如用户端登录、管理端登录、用户端菜品浏览依然被拦截前端一片401。排查思路很简单打开拦截器配置类把excludePathPatterns里配的路径和Controller上RequestMapping的路径逐个比对。很多人栽在这里是因为路径写得不一致比如Controller是/user/user但拦截器里配的是/user这当然放行不了。经验之谈配置放行列表时一定要把完整路径写对尽量以/user/**这种通配符覆盖子路径。如果本地自测时觉得麻烦可以先临时注释掉拦截逻辑跑通了再加回来但记得最后一定要恢复。4.2 菜品列表查不出来先确认Redis里有没有脏缓存用户端菜品分类查询用的是Spring Cache Redis。最典型的Bug场景是管理端修改了菜品用户端看到的还是老样子。原因基本集中在缓存没有失效。检查步骤三步走确认管理端修改菜品时有没有触发CacheEvict确认CacheEvict的key和用户端查询时Cacheable的key是否一致直接进Redis用keys命令查看所有相关缓存比对数据时间和菜品最新更新时间。这里有个坑是key生成策略。Spring Cache默认的key是“参数组合”如果管理端清除缓存用的key和用户端读取用的key完全不是一回事那缓存永远清不掉。解决办法就是统一定义缓存key的前缀和拼接规则比如dish_category_分类id不要图省事儿不写key直接用默认规则。4.3 超时订单没被扫描到先查time条件Spring Task定时任务跑了日志也打了但数据库里过期的订单就是没被取消。大多数情况下是SQL里的时间比较写错了。比如你把“下单时间小于当前时间减15分钟”写成了“下单时间大于”或者直接拿字符串比较时间都会导致筛选结果为空。在本地复现这个功能时我建议你造一条订单把下单时间改成20分钟以前然后手动执行一次定时方法观察日志和数据库。如果按真实场景等15分钟调试效率太低了。4.4 菜品口味新增失败多半是事务没生效在一个Service方法里同时插入dish和dish_flavor如果插入口味时报错而菜品却插入成功大概率是事务没包住。通常原因有两个类没有被Spring容器管理即缺Service注解方法被同类内部调用导致AOP代理失效。解决方式也很简单在public方法上加Transactional并确保调用方和被调用方在Spring容器中不要用this内部调用。要是你为了启动速度把某些Service排除出了包扫描范围那事务自然也不生效。4.5 支付回调验签失败先看证书和密钥格式模拟联调中回调验签失败的当场不必急着怀疑代码逻辑更常见的情况是你配置的API密钥是微信商户平台的老版密钥不是V3版本的APIv3密钥或者证书解出来多了一行换行符。最简单有效的方式是先把完整的回调报文原样打日志人工核对该报文里的字段再单独测试验签函数是否能对已知签名的数据返回true。验签单独通过之后再串进回调接口里看整体流程。5. 避坑经验分享个人实操心得刷苍穹外卖这种项目切忌眼高手低。代码轮子很容易敲出来但真正值的是把每条链路背后的“为什么”想明白。我个人的实操心得可以浓缩成三条第一先跑通再重构最后删了重写一遍。你跟着教程敲完整个项目只能算入门把它从自己脑子里按记忆重新实现一遍才算消化。删掉自己刚写完的代码这件事听起来很刺激但效果出奇地好。第二遍重写的时候你会开始考虑异常处理、状态枚举、DTO设计这些东西因为有第一遍的全局视角你会更自然地做出更好的设计决策。第二每做完一个模块就画一张模块关系图。不一定是正式文档可以是草稿纸上的圈圈线线。这个动作能帮你把Controller、Service、Mapper、数据库表之间的关系印在脑子里。面试官问“你做过的项目有哪些模块”你不至于只记得一堆方法名而是能讲清楚模块间怎么协作。第三不要跳过支付回调的模拟。很多人觉得本地没有商户号支付这块看看视频就好。但你一旦跳过后面面试被追问支付流程就会露怯。花一个下午用模拟工具走通一遍回调收益远比你想象的大。以后再聊到具体的模块时我们可以一个个把它们打透。比如订单状态机可以再细化出“取消订单”的库存回补逻辑比如用户端菜品浏览如何结合Redis做分类分页缓存比如管理端营业统计里的数据如何用SQL一次性聚合出来。慢慢来把每一期都吃透面试和实操就都不怕了。