ARTICLE DETAIL

资讯详情

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

苍穹外卖day6:订单模块核心链路与状态机流转实践

苍穹外卖day6:订单模块核心链路与状态机流转实践 咱们继续聊苍穹外卖day6。前面几天工作区还算是岁月静好到这天开始才是真正进入“订单”这个核心领域。你会发现之前写的分类、菜品、购物车、地址簿到了这一步全部串联起来了整个系统的业务主链路开始闭合。day6这天的内容核心就两个大块用户端下单 管理端订单处理。听起来好像功能不多但实际上这是整个项目里涉及状态流转最多、最容易出bug、也最考验工程思维的部分。我先说一下我当初做完这天的整体感受就一句话订单模块写好了你才算真的摸到了这个项目的骨架。前面的模块说白了都是数据增删改查但订单从生成到完结牵扯了库存逻辑、支付回调、并发修改、状态机、定时任务、WebSocket推送等多个问题你说它是个小分布式项目也不为过。我自己在写的时候反复调了好几次希望这篇能帮你把这些坑都提前填上。1. 整体设计思路订单模块不只是两张表1.1 为什么要先梳理状态流转先说设计思路。很多人一上来就写接口结果写到改订单状态的时候发现自己都不知道当前状态能不能跳到目标状态各种if嵌套到最后连自己都看晕了。我建议先把订单的状态流转图画在纸上再动手写代码。苍穹外卖的订单状态大概是这样一套流待付款1用户提交订单后生成此时库存还没真正扣减实际项目里这个看具体实现但咱们这个项目里是下单就锁库存待接单2用户完成支付后由支付回调触发商家端可以看到这笔订单已接单3商家点击接单此时进入配送环节派送中4商家点击派送骑手/商家开始配送已完成5用户确认收货或系统自动确认本项目一般是用户点击完成已取消6用户端取消、商家拒单、超时未支付系统自动取消一共六个状态每个状态的跳跃是有方向和前置条件的。比如已接单后用户不能直接取消必须联系商家派送中不能直接跳到已完成需要用户参与或商家操作。把这张图理清楚后面的状态字段更新逻辑就非常清楚了。我当时画完这张图之后发现自己之前想得太简单了。原来订单不是一个线性流程它是个带分支的状态机。支付失败要回到待支付超时要把订单取消掉退款又是一个独立的逻辑链条。这些分支如果没理顺就动手写后面扩展功能时处处想改表结构。1.2 订单模块涉及的核心表结构订单这个功能光靠一张表肯定撑不起来至少需要两张核心表orders订单主表存订单的基本信息包括订单号、下单用户、订单状态、支付状态、下单时间、结账时间、实收金额、备注、收件人姓名、电话、地址、预计送达时间、配送方式等。order_detail订单明细表存订单里的每一项菜品信息包括菜品id、菜品名称、菜品图片、菜品口味、份数、单价、小计金额。为什么要单独拆一张表因为订单完成之后菜品本身可能被修改、下架订单快照要有独立的数据副本不能去关联实时的菜品表。这个设计一定要理解它就是电商里常说的“快照”思路。除此之外还有一张关联的地址表逻辑但咱们项目里地址信息直接冗余进了order表所以不需要额外关联查询。这种“对历史行为做数据冗余”的思路在订单系统里特别重要。1.3 用户端和管理端功能划分day6的任务量不小我习惯把它分成两条线来看用户端要做的事情提交订单支付回调处理模拟微信支付查看历史订单订单详情取消订单再来一单这个功能挺有意思的很多人会忽略管理端要做的事情订单搜索条件查询分页各个状态的订单数量统计接单拒单派送完成订单订单详情查看我做的过程中发现管理端的功能看起来简单但状态校验相当严格。比如拒单时必须填写拒单原因已接单的订单不允许拒单派送中不能直接完成必须由用户点击或系统自动完成。这些逻辑如果你在service层没有把控好很容易出现数据错乱。2. 核心流程拆解从下单到支付回调2.1 用户下单接口的完整链路下单这个接口是我写order模块第一个动手的地方也是第一个把我绕晕的地方。它不是简单的insert一条记录就完事需要把一整条链路串起来。第一步封装请求参数。用户提交订单的时候传给后端的是地址簿id、付款方式、预计送达时间、备注、餐具数量、购物车数据一般通过token从redis里拿后端要根据这些构造出Orders对象和OrderDetail列表。这里有一个关键点就是地址簿数据不能直接存id要把收获人姓名、电话、详细地址这些字段值都复制进订单表。第二步向orders表插入订单主数据。这里的订单号生成就很有讲究了。用自增id太简单了呢。我用的是时间戳 用户id后四位 随机数来拼虽然不保证绝对唯一但配合唯一索引来判断重复足够了。你用System.currentTimeMillis()拼上用户id再乘上随机因子一般不会撞。实际项目中会引入分布式id或者雪花算法学习中用这个方式理解思路就够用了。第三步向order_detail插入明细数据。这个环节要从购物车中获取商品列表逐条构建OrderDetail对象并批量插入。注意这里必须先把orders表插入后拿到自增id再把订单明细表插进去否则外键关联就断了。同时插订单明细和清购物车这两个操作必须放在一个事务里确保要么一起成功要么一起失败。第四步清空购物车。下单成功后要把当前用户的购物车里的数据删掉。第五步返回订单id。这个订单id非常重要因为支付的时候要通过订单id回调来更新订单状态。返回值一般返回订单id前端拿到后去调支付模拟接口再轮询订单状态或者用WebSocket接收支付结果推送。2.2 支付回调处理为什么是回调而不是直接改状态这是我认为day6最有含金量的逻辑之一。很多人初学的时候容易犯一个错用户点击“支付”按钮前端直接调后端接口“支付成功把订单状态改了”。这其实是错的。真实项目中支付结果是由支付平台微信、支付宝异步通知给你的后端系统的前端根本不可信。咱们这个模拟项目里支付功能是通过一个微信支付的模拟接口实现的。逻辑大概是前端带上订单id发起支付请求后端生成一个模拟的支付二维码或者直接模拟支付成功支付平台这里是模拟代码回调后端一个支付成功通知后端在回调方法里校验订单号对应的金额是否匹配、订单是否存在校验通过后更新订单状态为待接单2同时更新支付时间和支付方式为什么强调回调因为支付成功和订单状态变更不是同步发生的。用户支付成功后可能需要几秒钟甚至更久才能收到回调通知。如果前端直接改状态一旦支付平台那边出了问题数据就对不上了。所以你要理解回调的本质最终状态以平台回调为准。从代码上来讲这个模拟回调方法里最核心的就是幂等处理。回调可能会重复调用比如网络抖动导致重试你必须在更新状态之前先判断当前订单状态是不是已经是“待接单”了如果是就直接return不做重复更新。我见过不少人漏掉这个判断导致订单金额被重复入账、状态被反复覆盖。2.3 超时未支付自动取消这个功能想必大家也很熟悉点外卖超过一定时间没支付订单就被系统自动取消了。这个功能怎么实现呢我做了两种方案对比。方案一使用Spring Task的定时任务。启动一个定时任务比如每分钟执行一次扫描那些“待付款”且下单时间超过15分钟的订单把它们改成“已取消”状态。这种方式实现简单但有一个问题如果订单量很大每分钟全表扫描压力很大。我们的项目规模很小这个方式完全够用。方案二使用延迟队列或消息中间件。下单后往延迟队列里面放入一个消息15分钟后取出判断订单是否已支付如果没支付就取消。这种方式更精准、性能也好但在学习阶段没有必要上这么重的中间件。我实际写的时候发现定时任务扫描还有一个坑订单取消后如果库存是下单时锁定的还需要回滚库存。虽然咱们这个项目里菜品余额是通过缓存管理的但是逻辑上一定要记得释放。这也是很多人在面试时被追问的点。2.4 再来一单的隐藏细节“再来一单”这个功能名字看起来很简单逻辑上就是把历史订单的明细再次加入购物车。但实现过程中有几个细节需要注意第一明细里的商品可能已经下架或删除此时不能直接加入购物车。第二商品可能改了价格用旧价格还是新价格一般来说你重新加入购物车的应该是按最新价格来算的。所以“再来一单”不能直接把order_detail的数据往购物车表里怼而是要根据菜品id再去查一下最新的菜品信息组装成购物车数据再插入。这个功能虽然小但让我意识到一个问题不要迷信快照数据用于一切场景。历史订单要用快照但“再来一单”需要实时数据二者目的不同用法也不同。3. 管理端订单模块查询、状态流转与WebSocket消息推送3.1 订单搜索的条件拼接与分页管理端订单搜索页是我见过的“最容易写成一坨”的接口。因为它的查询条件是动态组合的订单号模糊查询、手机号模糊查询、状态精确查询、下单时间区间范围查询而且还需要分页。我用MyBatis动态SQL来解决这个问题if标签判断条件非空就拼上。这里我给你一个非常实用的经验模糊查询手机号的时候不要用LIKE % || #{phone} || %这种写法要记得先做去空格处理否则前端传一个带空格的手机号查出来的结果就是空的。还有一个注意点订单号是字符串而且用户可能只记得后几位所以模糊查询要用like。但这里有个性能隐患如果数据量大LIKE %xx%是索引失效的会全表扫描。学习阶段无所谓真正的大项目里会用搜索引擎或者分库分表来解决。你要是能在项目总结里提到这个优化思路面试能加不少分。3.2 各个状态订单数量统计这算管理端一个首页的辅助功能需要一个接口统计出“待接单、待派送、派送中”等各个状态的订单数量。最简单粗暴的方式就是写6条count查询每个状态查一次。但我个人建议你用一条SQLgroup by status把结果查出来再用一个HashMap去接收前端展示的时候直接查Map里的key就行。这个优化说出来好像很小但实际写的时候省掉了6次数据库交互。我见过太多人写代码不关注查询次数一个统计页面里调了十几次数据库那真的是很影响性能的。你在这个项目里养成聚合查询的习惯后面工作中会很受益。3.3 接单/拒单/派送/完成的状态流转控制管理端最核心的操作就是这几个状态流转方法每个方法都有严格的校验逻辑。我把我的做法写下来你直接抄作业就行。接单把订单状态从“待接单”改成“已接单”。改之前要校验订单状态必须是“待接单”否则抛出业务异常。拒单把订单状态从“待接单”改成“已取消”。注意拒单必须填写拒单原因前端传一个reason字段后端把它更新到订单的cancel_reason字段里。同时拒单其实也是一种“取消”所以需要和用户取消订单共用一套后置逻辑如果库存是被锁的就需要回滚。派送把订单状态从“已接单”改成“派送中”。这里要注意接单和派送可以是两个操作也可以是一个操作商家一键接单并派送看需求。完成注意这个状态的触发方用户端点了“完成”或者系统自动确认。管理端的完成操作我理解一般是针对“已完成订单”的查询或管理这里可以不去纠结不同产品的差异把项目里的业务逻辑跑通即可。以上这些状态流转操作最核心的一点是每次都从数据库查一次最新订单状态再判断能不能改。绝对不要用前端传过来的订单状态做判断前端数据是不可信的一定要以DB的当前状态为准。3.4 WebSocket消息推送商家如何实时收到新订单这个功能我是真的要强调一下因为很多同学自己写项目的时候根本不会考虑推送但苍穹外卖里商家端需要实时看到新订单提醒如果用前端轮询实现一是延迟高二是压力大。所以项目里用了WebSocket。讲的简单点WebSocket就是浏览器和服务器之间的一条长连接通道服务器可以主动往浏览器推数据。在这里的使用场景是用户支付成功后后端在回调处理逻辑里主动通过WebSocket给商家管理端页面发送一条“新订单”消息商家端页面收到消息后弹出提示或者刷新订单列表。实现步骤也不复杂引入WebSocket依赖创建一个WebSocket端点类维护一个会话集合一般用ConcurrentHashMapkey是商家idvalue是Session在后端支付回调逻辑里调用WebSocket工具类向对应商家发送消息前端管理端页面在连接建立后监听消息事件收到消息后刷新列表这里要特别注意一个问题WebSocket的连接数量和线程安全问题。如果一个商家开了多个页面或者是多商家共存你推送的时候要遍历会话集合逐个发送。发送前判断session是否open如果已经关闭要移除否则会堆积失效会话内存会慢慢涨上去。我实际写完测试的时候发现自己本机连的时候没问题但换了个网络环境就推不过去了。排查了半天发现是本机防火墙挡了WebSocket的握手请求。所以如果你做这个功能时遇到推不过去的情况先检查一下是不是浏览器控制台有connection报错再看服务端日志不要对着代码瞎找半天。3.5 订单详情用户端和管理端的差异订单详情这个接口也有两个版本用户端查自己的订单详情管理端查任意用户的订单详情。看起来都是“查订单查明细”但权限校验完全不同。用户端查详情必须通过当前登录用户的id来匹配订单防止越权查询别人订单。管理端查详情只需要订单id就行因为管理端本身就需要查看任意订单。明细查询时要注意order_detail这个表里存的是“菜品快照”包括菜品图片、名称、口味等。这也是为什么订单详情接口不需要关联菜品表就能展示完整信息的原因。很多初学者会把这两个表搞混导致用户改了菜品信息后历史订单里的菜品名也跟着变了这就很尴尬了快照的意义就是历史不可变。4. 常见问题与排查实录我踩过的坑4.1 金额计算出现0.01元误差这个坑是真的经典我在写订单金额计算的时候直接用了Double来计算结果出现了一个非常微妙的问题——算出来的金额多了0.01元。最后查出来原因是Double的浮点精度丢失。比如0.1 0.2在Java的Double运算里结果是0.30000000000000004而在我们数据库里面金额字段往往又是decimal类型精度就对不上了。我的解决办法是所有金额计算统一使用BigDecimal并且构造的时候用字符串而非Double比如new BigDecimal(10.50)不要用new BigDecimal(10.50)。这一点极其重要你不止在订单模块要用以后凡是涉及到钱的业务都要养成用BigDecimal的习惯。4.2 事务失效状态没回滚做取消订单的时候我遇到了一个状态没回滚的情况。后来排查发现是因为我在Service方法里自调用了一个加了Transactional注解的方法导致事务没有生效。这个如果你不熟悉可以简单记一下Spring里通过this调用本类方法时注解不生效因为代理对象没有介入。解决办法就是把自己注入自己循环依赖不太建议或者把涉及事务的操作放到另一个Service类里或者在使用的时候从Spring容器中获取代理对象。这种情况在面试里经常被问到如果你能主动提出来说明你是真的写过并踩过坑而不是光背八股。4.3 状态修改丢失更新这个问题真的很隐蔽。写取消订单的时候在判断完状态、还没执行update的间隙如果用户同时在管理端把订单接单了两边可能都走到了“允许操作”这一步后update的一方就把先update的数据覆盖了。真正解决这个要引入乐观锁在订单表加version字段或者悲观锁select for update。学习项目里一般不需要做那么重但你要能意识到这个问题并能在文档里提出解决方案那就比90%的人都强了。我当时自己的做法是在敏感操作里加了一层状态校验就是UPDATE时带上status 旧状态这个条件去更新更新行数为0就代表状态已经被改变了直接报错。这个办法不需要加锁成本极低。4.4 WebSocket连不上这一天还有一个很容易遇到的坑就是WebSocket握手失败。具体表现是前端控制台一直报“Error during WebSocket handshake”后端日志也没有报错。我当时排查了很久发现竟然是因为本地用了Nginx代理而Nginx默认没配WebSocket的Upgrade头导致握手升级失败了。解决办法是在Nginx配置里给对应的location加上proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade;如果你没用Nginx是直接后端启动的那就要检查是不是端口写错了或者防火墙放行了这个端口没有。4.5 再来一单时菜品已下架前面提到过再来一单如果直接照着历史订单的detail去加购物车会出现下架菜品被重新加入购物车用户下单时才知道这个菜没了体验很差。我的解决方案是重新查询菜品表判断status是否为1上架状态如果菜品下架就跳过不加入购物车并且在前端提示用户有部分商品已下架无法加购。这个功能虽然小但想得全面产品体验就会好很多。5. 实操要点五个模块的落地步骤和代码关键点5.1 用户下单功能落地Controller层接收一个OrdersSubmitDTO包含地址簿id、付款方式、预计送达时间、备注、餐具数量。执行流程我已经列过了关键代码大概是这样的逻辑根据地址簿id获取完整地址信息从Redis中获取当前用户的购物车列表遍历购物车构建OrderDetail列表同时累加计算总金额设置订单号时间戳随机数、下单时间、订单状态为待付款插入orders表批量插入order_detail表清空购物车返回订单id给前端注意一定要把购物车数据的获取和清空放在同一个事务里。5.2 支付回调处理落地写一个专门的支付回调Controller方法接收订单id、支付金额等参数实际项目是加密的XML/JSON这里模拟简化。回调里要校验订单号、金额是否一致并且判断订单状态必须还是“待付款”才能更新为“待接单”。完成更新后紧接着就调用WebSocket工具类向商家推送新订单消息。这一步不要忘了不然管理端页面不会自动刷新。5.3 管理端状态流转落地管理端的几个接口正好对应几个Service方法每个方法都可以复用同一个“从数据库查订单-校验状态-更新状态”的三步套路。我建议你写一个OrderServiceImpl把六个状态的操作都放进去不要拆得太散。每个方法前的校验大概是这样的Orders orders orderMapper.getById(orderId); if (orders null || !orders.getStatus().equals(Orders.TO_BE_CONFIRMED)) { throw new BusinessException(订单状态错误无法操作); }然后执行状态更新必要时补充cancelReason、cancelTime、deliveryTime等字段。5.4 订单搜索落地管理端订单搜索接口用PageHelper分页配合MyBatis动态SQL做条件查询。这里我又要提一个细节查询结果返回给前端的时候最好把金额字段直接转成字符串或者前端能处理的类型防止Double序列化时精度出问题。5.5 订单统计落地状态数量统计就用一条group by STATUS的SQLselect status, count(*) from orders group by status查回来后遍历结果集放进Map保证每种状态都能返回一个数量前台就很好展示了。6. 最后的经验与思考day6做完之后我最大的感受就是苍穹外卖这个项目确实不是简单的CRUD练习它把一个真实外卖系统最核心的交易链路压缩到了几天的任务里。之前很多时候我们写代码是“功能能跑就行”但从订单模块开始你不得不去思考事务、状态机、幂等、并发、实时通信这些真正工程化的东西。我建议你在做这一天内容的时候不要光盯着代码能不能跑通而是要反复问自己几个问题为什么订单明细要单独存一张表为什么支付回调必须是后端收到通知而不是前端直接改状态为什么系统要支持超时未支付自动取消如果并发量上来当前这段代码会出什么问题这几个问题你如果能想明白那你这一天就算没白做。项目代码本身是固定的但你在思考中建立的工程直觉才是跟着你走的真正财富。后面的day7、day8还会有更多配合性的功能但核心链路到这一天基本就稳定了。把这一天的代码看熟、理顺后面你会越写越顺手。
返回列表