ARTICLE DETAIL

资讯详情

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

订单状态流转与WebSocket实时推送:外卖管理端订单模块开发实战

订单状态流转与WebSocket实时推送:外卖管理端订单模块开发实战 苍穹外卖这个项目做到第十二天日志的写法明显和头几天不一样了。前阵子主要在搭架子员工管理、菜品、分类、用户端下单支付都跑通了今天要处理的是订单模块里最考验细节的一刀——管理端的订单条件搜索、接单/拒单/派送/完成这一串状态操作以及来单提醒。这篇日记主要记录我在实现这些功能时踩过的坑尤其是WebSocket推送和状态并发控制这两块常规教程很少展开讲。如果你也在做外卖类项目或者所处项目里有“订单状态流转”和“实时消息推送”这两个需求这天的记录应该能帮你少走弯路。1. 第11天结束时卡住的点管理端订单模块的完整拼图1.1 为什么C端下单跑通后管理端反而更麻烦第十天和第十一天用户端核心流程基本走完了小程序提交订单、模拟支付回调、历史订单列表、取消订单。数据库里已经积累了真实订单数据但管理端这边还是一片空白——商家登录后台看不到自己有哪些订单来了新单也没有任何提示只能靠用户打电话催。这种情况放到真实业务里是没法用的所以第十二天上午一开始我就把管理端订单模块拆成了四个任务订单条件搜索按订单号、手机号、状态、时间段筛选分页展示订单状态操作接单、拒单、派送、完成四个动作来单提醒商家端实时收到新订单通知C端催单用户催单后商家端能收到催单提醒表面看都是CRUD真正写的时候才发现订单模块和前面做的菜品类完全不是一个难度。菜品无非是增删改查加个上传但订单天然带状态、带金额、带支付回调、带并发边界稍不注意就会出一堆隐蔽问题。1.2 先画订单状态图再写接口动手写代码之前我花了半小时把订单状态图画了一遍这也是今天我认为最值的一步。苍穹外卖的订单状态整体是这样的状态名称触发动作允许流向1待付款用户提交订单支付成功、取消2待接单支付成功回调接单、拒单3待派送商家接单派送4派送中商家派送完成5已完成用户确认收货无6已取消用户取消、商家拒单、超时取消无为什么非要先画这张图因为订单模块里所有接口都是围绕状态流转设计的状态图一旦画错后面写多少接口都是错的。我见过网上不少项目的订单接口拒单和取消没区分接单之后还能拒单状态乱跳最后统计报表数据一团糟。这张图看起来简单但它是整天的地基。2. 订单条件搜索一条动态SQL的体验2.1 为什么不直接拼SQL搜索接口的需求很直白管理端页面支持按订单号、手机号、订单状态、下单时间段组合筛选还要分页。我第一版写的时候想过直接用一条固定SQL加LIMIT然后Java代码里判断条件往SQL上拼字符串。写到一半就放弃了因为条件组合太多拼接代码又丑又容易SQL注入维护起来非常痛苦。最后用MyBatis动态SQL实现。核心思路是where标签加if判断有多少条件就写多少分支空的条件自动忽略select idpageQuery resultTypecom.sky.entity.Orders SELECT * FROM orders where if testnumber ! null and number ! AND number LIKE CONCAT(%, #{number}, %) /if if testphone ! null and phone ! AND phone LIKE CONCAT(%, #{phone}, %) /if if teststatus ! null AND status #{status} /if if testbeginTime ! null AND order_time gt; #{beginTime} /if if testendTime ! null AND order_time lt; #{endTime} /if /where ORDER BY order_time DESC /selectwhere标签最大的好处是自动处理WHERE关键字如果所有条件都为空它不会生成多余的WHERE如果第一个条件前面有AND它会自动去掉。这样SQL就干净很多。配合PageHelperpageNum和pageSize直接传进来就能分页省了不少事。2.2 两个特别容易踩的坑status0和时间区间第一个坑是关于Integer类型的判断。写动态SQL时很多人习惯把status当成字符串处理写成teststatus ! null and status ! 。这在OGNL表达式里有个大坑如果status是Integer类型且值等于0那status ! 这个判断会被当成false条件直接被跳过。也就是说你想筛选“待付款”订单结果返回了所有状态的订单。正确写法是Integer类型只判断status ! null不要画蛇添足加空字符串判断。第二个坑是时间区间。搜索页面通常会有“开始时间”和“结束时间”两个选择器但用户可能只选一个比如只看“2024-05-20之后的订单”。这时候如果SQL里写死AND order_time begin AND order_time end而end是null查询结果就空了。我的做法是对每个时间参数单独if判断缺哪个就只过滤哪个。另外要注意XML里和要写成gt;和lt;否则XML解析直接报错这个我第一年写MyBatis时栽过。还有个性能问题顺便提一下。phone字段如果经常被单独查询建议建普通索引而ORDER BY order_time DESC配合时间范围查询数据量大时也考虑一下组合索引。订单号的LIKE %xxx%是没办法走索引的所以实际操作中要让用户尽量带上时间范围缩小扫描集。3. 接单、拒单、派送、完成状态流转接口里的边界地狱3.1 先查状态再改状态的问题在哪状态操作接口逻辑看起来都差不多拿订单ID查订单判断当前状态更新成目标状态。但这里有个经典的并发隐患我先说一个反面案例Orders order orderMapper.getById(orderId); if (order.getStatus() ! 2) { throw new RuntimeException(当前订单状态不允许接单); } orderMapper.updateStatus(orderId, 3);假设两个管理员同时处理同一张订单两个请求都查到了订单状态是2都通过了校验然后都去执行更新。表面看问题不大但实际会产生两个问题一是业务日志里出现两次“接单成功”排查审计的时候说不清到底谁操作的二是如果后来加了自动化接单逻辑这种竞态条件会被放大。更稳的做法是带原状态条件的更新让数据库来做最后的把关int rows orderMapper.updateStatusByOriginalStatus(orderId, 2, 3); if (rows 0) { throw new RuntimeException(订单状态已变化请刷新后重试); }对应Mapper SQL是UPDATE orders SET status #{targetStatus} WHERE id #{id} AND status #{originalStatus}。受影响行数只有1说明这次更新是真的把状态从2改成了3返回0说明订单状态早就被别的请求改了。这种方式不用引入分布式锁简单粗暴但非常有效我建议所有状态流转接口都这么写。3.2 拒单处理退款不能只改状态四个状态接口里接单、派送、完成都还算直接真正麻烦的是拒单。因为用户已经付过钱了商家拒单钱必须退回去。如果只把状态改成“已取消”那用户钱就莫名其妙没了。我当时的设计思路是校验订单状态必须是2待接单修改订单状态为6已取消记录拒单原因插入一条退款流水状态为“退款中”调用支付平台退款接口学习阶段用模拟实现退款成功更新退款流水状态为“退款成功”退款失败则保留“退款中”后续用定时任务补偿有个顺序问题值得注意不能先退款再改状态。如果退款成功了但订单状态没改成功用户会以为订单还挂着同时钱已经退了两边对不上。也不能只改状态不退款那是耍流氓。所以我的做法是同一个事务里先改状态、插退款流水事务提交后再去调支付接口。这样即使退款失败状态和流水的数据是一致的靠补偿任务能捞回来。3.3 把状态校验抽成公共方法四个接口都有“判断当前状态允不允许操作”的逻辑第一版我直接在各接口里写了四份差不多的if判断。写到第三个接口时实在看不下去了抽了一个公共方法private Orders checkOrderStatus(Long orderId, Integer expectStatus, String operation) { Orders order orderMapper.getById(orderId); if (order null) { throw new RuntimeException(订单不存在); } if (!order.getStatus().equals(expectStatus)) { throw new RuntimeException(当前订单状态不允许 operation); } return order; }后面接单、拒单、派送、完成都复用这个方法再配合带条件的UPDATE做并发兜底。校验方法负责给用户友好的提示条件UPDATE负责处理并发边界两个组合在一起状态流转才算靠谱。4. 来单提醒从轮询到WebSocket的一次升级4.1 为什么不用轮询而是WebSocket来单提醒是今天技术含量最高的部分。最传统的方案是前端轮询商家页面每2秒发一次请求问后端“有没有新订单”。这个方案实现简单但代价是高峰期一百个商家同时在线轮询请求会把服务端打得很难受。而且轮询还做不到真正的实时延迟至少是轮询间隔的时间。WebSocket就不一样了。它是服务端主动推平时连接空闲不占请求资源来单的时候直接推给对应商家毫秒级送达。对比起来很明显方式实时性服务端压力实现复杂度适用场景HTTP轮询秒级延迟高低低频通知WebSocket毫秒级延迟低中实时来单、客服IM4.2 服务端推送的关键代码Spring Boot集成WebSocket不复杂核心是写一个处理器维护在线商家的Session。我用ConcurrentHashMap存商家ID和Session的对应关系为什么用它而不是普通HashMap因为推送是并发操作多个线程同时往Map里放连接时普通HashMap在扩容期可能丢数据。Component public class OrderWebSocketServer extends TextWebSocketHandler { private static final MapString, WebSocketSession SESSION_MAP new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) { String sid (String) session.getAttributes().get(sid); SESSION_MAP.put(sid, session); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) { // 心跳响应客户端发ping服务端回pong if (ping.equals(message.getPayload())) { session.sendMessage(new TextMessage(pong)); } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { String sid (String) session.getAttributes().get(sid); SESSION_MAP.remove(sid); } public void sendToBusiness(String businessId, String payload) { WebSocketSession session SESSION_MAP.get(businessId); if (session ! null session.isOpen()) { session.sendMessage(new TextMessage(payload)); } } }配置类里把/ws/{sid}映射到这个处理器握手时从路径参数里取出sid放进Session的attributes。这样后面C端下单成功、支付回调完成后就能按商家ID把消息推给对应用户orderWebSocketServer.sendToBusiness( String.valueOf(businessId), {\type\:1,\orderId\:123,\msg\:\您有一个新订单\} );注意一条推送要放在事务提交之后。如果事务还没提交就推送商家端看到新单但去查详情时数据还没提交会出现短暂的“接了单但单子不存在”。我用的是Spring的TransactionSynchronizationManager.registerSynchronization在afterCommit回调里做推送这样能保证数据一致。4.3 商家端接收与提示音商家后台是浏览器页面前端连接代码很简单const ws new WebSocket(ws://localhost:8080/ws/ businessId); ws.onmessage function (event) { const data JSON.parse(event.data); if (data.type 1) { playAudio(); refreshOrderList(); } };提示音我用一个隐藏的audio标签playAudio里把currentTime归零再play()防止连续来单时声音播不出来。这里有个小细节很多浏览器要求页面必须有过用户交互才能自动播放音频所以第一次打开页面时我会让管理员点击一次空白区域“激活”音频权限否则来单提示音第一次往往不响。4.4 我遇到的假死连接问题WebSocket跑通之后我以为今天最难的坎过去了结果下午改完服务端代码重启问题立刻来了。商家页面还挂在旧的连接上浏览器开发者工具里状态显示OPEN但实际上服务端早就换了容器旧Session已经失效。新订单进来时sendToBusiness往废弃的Session发消息不会抛异常但消息石沉大海商家端毫无反应。这个问题让我折腾了快一个小时。最后处理方案是双管齐下客户端监听onclose事件断线后1秒自动重连并且每30秒主动发送一个ping服务端推送消息时用try-catch包住一旦发送失败就把Session从Map里移除让客户端下次重连重新建立连接另外如果后面用Nginx做反向代理还要配升级请求头不然WebSocket握手根本过不去proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s;5. 催单接口和整个订单闭环的测试复盘5.1 催单不是无脑推送要有频控和状态校验催单接口是C端用户在订单等待时点“催一下”对应的接口是PUT /user/order/reminder/{orderId}。这个接口看起来简单实际上有两个隐藏需求。第一是状态校验。只有“待接单”的订单才需要催单如果订单已经在派送中或者已完成催单就没有意义直接返回提示即可。第二是频率控制。如果不限制用户一着急就连点十下商家端提示音响成一片反而影响操作。我用Redis做了一个简单的频控String key reminder: userId : orderId; Boolean success redisTemplate.opsForValue().setIfAbsent(key, 1, 5, TimeUnit.MINUTES); if (Boolean.FALSE.equals(success)) { throw new RuntimeException(您已催单请耐心等待); }这个方案的思路是把“5分钟内是否催过单”这个状态直接存在Redis里天然带过期时间比建一张数据库表干净得多。催单动作本身通过WebSocket推给商家端消息里带type2标记。5.2 全链路测试暴露的问题下午我把整个闭环完整测了一遍用户下单 → 模拟支付回调 → 商家端收到来单提醒 → 商家接单 → 派送 → 用户确认完成。这个流程跑下来非常爽但也暴露了三个问题第一个是“更新订单状态”和“推送来单提醒”的顺序问题。我刚才是用事务提交后推送解决的但如果推送失败商家端还是收不到提醒。目前我的策略是推送失败只记录日志、不影响主流程然后靠前端轮询补偿——商家后台页面每隔一段时间刷新一次订单列表万一推送丢了刷新也能看到新单。算是一个过渡方案后续如果要求更高可以把推送失败的消息存到Redis里做补偿重推。第二个是重复推送问题。支付回调在某些场景下可能触发两次比如支付平台重试回调如果不做幂等商家端会收到两条来单提醒。我的解决方法是订单表里加一个“已通知”标记推送成功后置位第二次回调进来发现已经通知过就直接跳过。第三个是状态校验提示不够友好。一开始催单接口对已经派送的订单直接返回“催单失败”测试时觉得这个文案太生硬后来改成“您的订单已在派送中请耐心等待”用户体验会好很多。5.3 日志是排查这类问题的第一工具今天排查WebSocket假死问题时我发现日志帮了大忙。每个状态操作接口入口我都打印了订单ID、当前状态、操作人结束时打印目标状态和耗时。这样在测试环境复现问题翻一下日志就能定位到是哪一步卡的不用靠猜。给所有订单相关的Service方法加上入参出参日志这个习惯看起来不起眼但在联调阶段能省半天时间。我见过太多人上来就说“这个接口有问题”结果日志一开发现根本没走到那个分支。6. 今天印象最深的bug以及下一步安排6.1 一个让我印象深刻的bug今天印象最深的不是某个复杂算法而是WebSocket的“假死连接”。整个链路里服务端认为连接还在客户端也认为连接还在但消息就是过不去而且连报错都没有。后来我甚至怀疑是JSON序列化问题折腾半天才想到是服务端重启后Session失效。这个bug给我最大的教训是WebSocket虽然叫长连接但并不像TCP那样天然保活中间任何一层断掉浏览器休眠、Nginx超时、服务端重启连接都可能变成“看起来活着实际上死的”状态。所以心跳必须做重连必须做发送异常处理必须做三件套少一个都不踏实。6.2 明天准备做的定时任务与报表今天订单模块的实时链路算是通了但整个管理端还有两块收尾工作一块是工作台数据统计今日营业额、订单数量、订单完成率、新增用户这些指标另一块是报表导出把订单和营业数据导成Excel。这两块依赖的都是刚做完的订单状态数据统计口径可以直接从订单状态枚举里取。还有两个定时任务也要补上超时未支付订单自动取消以及超时未接单订单自动提醒或取消。这两块用Spring Task就能做核心还是状态机的边界判断有了今天这张状态图写起来应该会顺畅很多。第十二天最大的感受是订单模块这种强业务逻辑的功能真的不能上来就埋头写接口。先把状态流转、并发边界、推送可靠性想清楚后面所有功能都建立在这个地基上返工成本会小很多。晚上收工时我特意把状态图贴在了项目文档最上面明天做统计口径和定时任务还要频繁对着它看。
返回列表