咱们继续聊苍穹外卖,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查询,每个状态查一次。但我个人建议你用一条SQL,group by status把结果查出来,再用一个HashMap去接收,前端展示的时候直接查Map里的key就行。
这个优化说出来好像很小,但实际写的时候省掉了6次数据库交互。我见过太多人写代码不关注查询次数,一个统计页面里调了十几次数据库,那真的是很影响性能的。你在这个项目里养成聚合查询的习惯,后面工作中会很受益。
3.3 接单/拒单/派送/完成的状态流转控制
管理端最核心的操作就是这几个状态流转方法,每个方法都有严格的校验逻辑。我把我的做法写下来,你直接抄作业就行。
接单:把订单状态从“待接单”改成“已接单”。改之前要校验订单状态必须是“待接单”,否则抛出业务异常。
拒单:把订单状态从“待接单”改成“已取消”。注意,拒单必须填写拒单原因,前端传一个reason字段,后端把它更新到订单的cancel_reason字段里。同时,拒单其实也是一种“取消”,所以需要和用户取消订单共用一套后置逻辑(如果库存是被锁的,就需要回滚)。
派送:把订单状态从“已接单”改成“派送中”。这里要注意,接单和派送可以是两个操作,也可以是一个操作(商家一键接单并派送),看需求。
完成:注意这个状态的触发方,用户端点了“完成”,或者系统自动确认。管理端的完成操作我理解一般是针对“已完成订单”的查询或管理,这里可以不去纠结不同产品的差异,把项目里的业务逻辑跑通即可。
以上这些状态流转操作,最核心的一点是:每次都从数据库查一次最新订单状态,再判断能不能改。绝对不要用前端传过来的订单状态做判断,前端数据是不可信的,一定要以DB的当前状态为准。
3.4 WebSocket消息推送:商家如何实时收到新订单
这个功能我是真的要强调一下,因为很多同学自己写项目的时候根本不会考虑推送,但苍穹外卖里商家端需要实时看到新订单提醒,如果用前端轮询实现,一是延迟高,二是压力大。所以项目里用了WebSocket。
讲的简单点,WebSocket就是浏览器和服务器之间的一条长连接通道,服务器可以主动往浏览器推数据。在这里的使用场景是:用户支付成功后,后端在回调处理逻辑里,主动通过WebSocket给商家管理端页面发送一条“新订单”消息,商家端页面收到消息后弹出提示或者刷新订单列表。
实现步骤也不复杂:
- 引入WebSocket依赖
- 创建一个WebSocket端点类,维护一个会话集合(一般用ConcurrentHashMap,key是商家id,value是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的SQL:
select status, count(*) from orders group by status查回来后遍历结果集放进Map,保证每种状态都能返回一个数量,前台就很好展示了。
6. 最后的经验与思考
day6做完之后,我最大的感受就是:苍穹外卖这个项目确实不是简单的CRUD练习,它把一个真实外卖系统最核心的交易链路压缩到了几天的任务里。之前很多时候我们写代码是“功能能跑就行”,但从订单模块开始,你不得不去思考事务、状态机、幂等、并发、实时通信这些真正工程化的东西。
我建议你在做这一天内容的时候,不要光盯着代码能不能跑通,而是要反复问自己几个问题:
- 为什么订单明细要单独存一张表?
- 为什么支付回调必须是后端收到通知而不是前端直接改状态?
- 为什么系统要支持超时未支付自动取消?
- 如果并发量上来,当前这段代码会出什么问题?
这几个问题你如果能想明白,那你这一天就算没白做。项目代码本身是固定的,但你在思考中建立的工程直觉,才是跟着你走的真正财富。后面的day7、day8还会有更多配合性的功能,但核心链路到这一天基本就稳定了。把这一天的代码看熟、理顺,后面你会越写越顺手。