news 2026/10/8 15:05:13

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
订单状态流转与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 id="pageQuery" resultType="com.sky.entity.Orders"> SELECT * FROM orders <where> <if test="number != null and number != ''"> AND number LIKE CONCAT('%', #{number}, '%') </if> <if test="phone != null and phone != ''"> AND phone LIKE CONCAT('%', #{phone}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="beginTime != null"> AND order_time &gt;= #{beginTime} </if> <if test="endTime != null"> AND order_time &lt;= #{endTime} </if> </where> ORDER BY order_time DESC </select>

<where>标签最大的好处是自动处理WHERE关键字:如果所有条件都为空,它不会生成多余的WHERE;如果第一个条件前面有AND,它会自动去掉。这样SQL就干净很多。配合PageHelper,pageNum和pageSize直接传进来就能分页,省了不少事。

2.2 两个特别容易踩的坑:status=0和时间区间

第一个坑是关于Integer类型的判断。写动态SQL时,很多人习惯把status当成字符串处理,写成test="status != 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 拒单处理退款,不能只改状态

四个状态接口里,接单、派送、完成都还算直接,真正麻烦的是拒单。因为用户已经付过钱了,商家拒单,钱必须退回去。如果只把状态改成“已取消”,那用户钱就莫名其妙没了。

我当时的设计思路是:

  1. 校验订单状态必须是2(待接单)
  2. 修改订单状态为6(已取消),记录拒单原因
  3. 插入一条退款流水,状态为“退款中”
  4. 调用支付平台退款接口(学习阶段用模拟实现)
  5. 退款成功,更新退款流水状态为“退款成功”;退款失败则保留“退款中”,后续用定时任务补偿

有个顺序问题值得注意:不能先退款再改状态。如果退款成功了但订单状态没改成功,用户会以为订单还挂着,同时钱已经退了,两边对不上。也不能只改状态不退款,那是耍流氓。所以我的做法是同一个事务里先改状态、插退款流水,事务提交后再去调支付接口。这样即使退款失败,状态和流水的数据是一致的,靠补偿任务能捞回来。

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毫秒级延迟低中实时来单、客服IM

4.2 服务端推送的关键代码

Spring Boot集成WebSocket不复杂,核心是写一个处理器,维护在线商家的Session。我用ConcurrentHashMap存商家ID和Session的对应关系,为什么用它而不是普通HashMap?因为推送是并发操作,多个线程同时往Map里放连接时,普通HashMap在扩容期可能丢数据。

@Component public class OrderWebSocketServer extends TextWebSocketHandler { private static final Map<String, 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推给商家端,消息里带type=2标记。

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就能做,核心还是状态机的边界判断,有了今天这张状态图,写起来应该会顺畅很多。

第十二天最大的感受是,订单模块这种强业务逻辑的功能,真的不能上来就埋头写接口。先把状态流转、并发边界、推送可靠性想清楚,后面所有功能都建立在这个地基上,返工成本会小很多。晚上收工时我特意把状态图贴在了项目文档最上面,明天做统计口径和定时任务还要频繁对着它看。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 15:04:15

Linux信号机制详解:从kill命令到SIGPIPE与僵尸进程排查

搞Linux的人早晚都得和“信号”打交道。你写了个服务跑得好好的&#xff0c;突然进程没了&#xff0c;日志上什么错都没有&#xff1b;或者你想让Nginx重读一下配置&#xff0c;实际上只需要给主进程发一个HUP信号&#xff1b;再或者后端程序一接客户端就崩&#xff0c;报错信息…

作者头像 李华
网站建设 2026/10/8 15:03:14

Windows Server 2012 R2 IIS离线安装包制作与部署指南

简介&#xff1a;本资源是专为Windows Server 2012 R2系统定制的IIS离线安装包&#xff0c;面向企业IT运维人员、系统管理员及无网络环境下的服务器部署工程师&#xff0c;解决内网隔离、安全策略严格或带宽受限场景下无法在线启用IIS角色的核心痛点。压缩包为ZIP格式&#xff…

作者头像 李华
网站建设 2026/10/8 15:02:56

Hive查询重写优化实战:从慢SQL到19分钟收工的改造路径

先抛一个我踩过很多次的坑&#xff1a;生产环境里一段Hive SQL跑了一个多小时&#xff0c;任务失败率居高不下&#xff0c;集群报警一封接一封。运维兄弟第一反应是扩容、调参数、加队列&#xff0c;结果折腾一晚上&#xff0c;执行时间只从90分钟降到75分钟。后来我静下心把SQ…

作者头像 李华
网站建设 2026/10/8 15:00:34

LeetCode 160 相交链表:双指针解法与哈希集合详解

我最近在整理自己的每日一题笔记&#xff0c;正好写到相交链表这道经典题。它是LeetCode第160题&#xff0c;也是链表模块里面试出现频率极高的一个。原题有很多马甲&#xff0c;比如找两个单链表的交点、判断两条链表是否合并过&#xff0c;但内核都是同一件事&#xff1a;给定…

作者头像 李华