2026/10/10 10:31:31

苍穹外卖订单模块开发实践:状态机、事务与定时任务的工程落地

苍穹外卖订单模块开发实践:状态机、事务与定时任务的工程落地 苍穹外卖做到第九天说实话前面的菜品分类、套餐管理、购物车这些都属于增删改查套路熟了之后写起来很快。但一到订单这块整个项目的难度上一个台阶——订单不是单表操作它牵扯到购物车、地址簿、订单主表、订单明细表好几张表的联动还有状态流转的校验、定时任务处理超时单以及用户端和商家端两套完全不同的视角。这篇就把day09订单相关操作的实现逻辑完整梳理一遍从业务设计到代码落地再到我实际调试过程中踩过的坑一次性讲清楚。1. 订单操作到底难在哪先想清楚业务边界再动手写代码1.1 订单模块在苍穹外卖整体架构里的位置苍穹外卖这个项目做到第九天已经有用户端小程序、APP和商家端后台管理系统两条业务线了。用户端的核心诉求是点餐吃饭商家端的核心诉求是处理餐品。购物车、地址簿、菜品浏览这些都是用户端下单前的前置操作而订单模块就是这两条线的交汇点用户端提交订单产生数据商家端对这些数据进行接单、派送、完成等操作。所以day09的订单操作表面上看是用户端几个接口 商家端几个接口但本质上它是一个完整的订单生命周期管理。任何一个状态处理不好比如用户取消了订单但商家还在派送或者支付状态和订单状态对不上都会导致业务事故。先看订单模块涉及的表结构。主要就是两张表订单表orders和订单明细表order_detail。订单表存订单的整体信息包括订单号、金额、状态、备注、收货人信息、下单时间等订单明细表存每一道菜的信息比如菜品名称、数量、单价、小计金额。一张订单对应多条明细典型的一对多关系。另外还有两张辅助表购物车表shopping_cart和地址簿表address_book。下单的时候要读购物车数据生成订单收货地址要从地址簿里取下单成功后要清空购物车。这里有四个操作落在三张以上表上事务处理就是必须的了后面会详细说。1.2 订单状态机所有操作的核心枢纽订单模块里最重要的设计就是订单状态。这个状态字段贯穿用户端和商家端的所有操作搞清楚状态流转比会写接口重要得多。苍穹外卖里订单状态用整数表示状态值含义对应阶段1待付款用户已下单但未支付2待接单用户已支付商家还没接单3已接单商家已接正在制作4派送中骑手已取餐正在配送5已完成订单已送达或用户确认完成6已取消订单被取消整个流程终止用户端可以执行的操作是提交订单创建状态为1的单子、模拟支付1→2、取消订单1或2→6、再来一单查询订单重新生成购物车数据。商家端可以执行的操作是接单2→3、拒单2→6、派送3→4、完成4→5。这里最关键的一点是每个操作都必须校验当前状态。不能出现订单还在待付款商家就点了接单这种状态跳跃。这是我带过几个同学写day09时最容易漏的地方后面单独开一节说校验逻辑。1.3 用一个状态字段还是多个字段为什么选这个方案有的系统喜欢把状态拆成多个字段比如是否支付是否接单是否派送各管各的。但苍穹外卖用了单一的状态字段理由很实际一是业务状态本身是互斥的一个订单在任何时刻只能处于一个状态拆开反而容易出现已支付但未接单和未支付但已接单这种非法组合二是前端展示和后端判断都简单一个switch就能搞定。单一状态字段的设计前提是状态流转必须严格受控也就是所有状态变更都集中在Service层的方法里不允许在Controller里随意改status。这一点在代码规范上值得强调一下。2. 用户端提交订单从购物车到订单落库的完整链路2.1 下单接口的入参设计DTO到底该包含什么提交订单的接口是用户端最核心的接口路径通常是/user/order/submit请求方式是POST。接口入参需要哪些字段打开用户端下单页面看顾客选完菜进入确认页能看到收货地址从地址簿选、送达时间、备注、购物车里的菜品清单和总金额。所以提交订单的DTO核心字段就三个地址簿IDaddressBookId、送达时间estimatedDeliveryTime、备注remark。菜品清单不需要前端传——后端直接查当前登录用户的购物车表这样能防止前端篡改菜品和金额。这是订单模块里一个很重要的安全设计下单金额以后端计算的为准前端传的价格一律不信任。很多项目在初期图省事让前端传总金额结果被薅羊毛的案例不少。苍穹外卖的做法是对的订单金额全部由后端根据购物车明细实时计算。地址也是只传ID后端起地址簿表查完整收货人信息再冗余到订单表里。为什么要冗余而不是下单后每次都去联表查因为地址簿是可变数据用户可能修改地址但历史订单必须保留当时的配送信息。这也是电商系统里很常见的数据快照思想。2.2 下单逻辑拆解四步操作和一个事务提交订单的Service方法逻辑拆开看是标准四步第一步查询购物车数据。根据当前登录用户的ID查出这个用户购物车里的所有菜品同时计算总金额。购物车表里菜品是按口味套餐组合拆行的所以同一道菜可能有多条记录。第二步插入订单主表。这是核心中的核心。订单号怎么生成苍穹外卖里用了时间戳加随机数的方案比如System.currentTimeMillis()拼上随机几位数。说实话这个方案在真实商城里不够严谨高并发下可能撞号但作为学习项目完全够用也能讲清楚订单号生成的基本思路。订单号在业务里很重要用户退款、对账、物流查询都靠它。订单主表的字段包括订单号、订单状态初始为1待付款、下单时间、金额、收货人姓名/电话/详细地址、预计送达时间、备注等。这些数据大部分来自DTO和上下文对象收货人信息要从地址簿表查出来再逐个set进去。第三步批量插入订单明细表。遍历购物车集合把每一条购物车记录转换成订单明细记录。注意明细表要保存的是下单那一刻的菜品快照——菜品名称、图片、单价都要存下来因为商家之后改价或者删菜品历史订单的展示不能跟着变。第四步清空购物车。把当前用户的购物车记录全部删除。这四步必须放在同一个事务里用一个Transactional注解搞定。其中任何一步失败前面插入的数据都要回滚否则会出现订单创建了但购物车没清空或者明细没插入但主表有了这种脏数据。2.3 事务为什么必须加模拟一次下单失败的场景我见过有同学把四步代码写完事务注解忘了加单测时又恰好每一步都成功等真正代码评审才被发现。为什么必须加事务设想一个场景插入订单主表成功插入订单明细时数据库报错比如某道菜的分数字段超长如果没有事务主表里多了一条没有明细的孤儿订单用户不但没下单成功之后查历史订单还会看到一条奇怪的空订单。时间长了表里全是垃圾数据对账也核不上。把Transactional加在Service方法上Spring会在方法执行期间管理数据库连接的事务。任何一步抛出RuntimeException整个方法里的数据库操作全部回滚。注意事务注解默认只对运行时异常生效如果代码里catch了异常然后吞掉不重新抛出事务照样不回滚。这一点我在排错记录那节还会再提。2.4 下单后的返回订单ID和预估信息下单接口的返回对象一般包含订单ID、订单金额、订单状态等前端拿到后跳到支付确认页。这里有个小细节下单之后订单状态是待付款用户可能直接退出不支付了这些单子怎么处理苍穹外卖的方案是定时轮询扫描超时未支付自动取消后面第5节专门讲。3. 模拟支付与取消订单状态流转的守门员逻辑3.1 模拟支付为什么只是改状态支付集成的学习路线苍穹外卖里的支付是模拟的。为什么模拟因为真实接入微信支付需要商户号、证书、回调接口而且涉及真实资金交易学习项目不可能也不应该接真钱。模拟支付的核心思路是调用一个内部的方法直接把订单状态从1待付款改成2待接单。这个设计在项目里是合理的但理解的时候要清楚真实系统怎么做的真实支付走支付平台用户付款后平台回调商户后端后端在回调里校验金额、更新订单状态、触发后续业务。苍穹外卖把回调这一步简化成了一个前端点击触发的内部方法。等以后做毕业设计或者真实项目只要把模拟支付的内部调用替换成真实的支付回调即可订单状态机不用变。模拟支付的接口逻辑先根据订单ID查出订单校验订单属于当前用户且处于待付款状态然后设置状态为待接单、设置支付时间。如果用户还没选菜就去支付订单都查不到如果订单已经是已接单状态又调支付接口状态校验要拦住。3.2 取消订单的状态校验没有校验的取消就是事故取消订单接口的路径通常是/user/order/cancel/{id}。这里的核心逻辑是状态校验——只有待付款和待接单状态的订单可以取消。已接单、派送中、已完成的订单骑手都在路上了用户点取消不能真取消否则商家做了饭、骑手送了单最后用户白吃这个损失谁担正确做法是查到订单后判断订单状态是否为1或2不满足则抛业务异常提示当前订单状态不允许取消。满足则改状态为6已取消如果是待接单状态还要模拟退款——给用户账户金额加回去。苍穹外卖里退款也是模拟的写一行更新用户余额的SQL即可。这里要注意一个细节用户和订单的归属校验。用户端接口的ID是用户自己的但理论上要防止A用户传B用户的订单ID去取消。所以查询订单时要带上用户ID作为查询条件比如orderMapper.getByUserIdAndOrderId(userId, orderId)而不是只按订单ID查。这是权限控制的最基础形态。3.3 再来一单把订单明细逆向写回购物车再来一单这个功能的本质是用户把之前某个订单的菜品重新加入购物车。实现思路很直接根据订单ID查出订单明细列表遍历每条明细重新封装成购物车记录插入购物车表。有几个坑我在写的时候就踩了。一是购物车表有口味字段订单明细表可能没有独立存口味或者存法不一致导致回写时口味丢失。苍穹外卖的订单明细表有口味字段所以回写没问题但你要检查自己的表结构。二是购物车表有菜品ID和套餐ID两个字段回写时要判断明细是菜品还是套餐分别填充对应字段不能一个set搞定。三是价格字段购物车的单价应该用下单时的单价还是菜品表当前的最新价格从再来一单的语义看用户就是要重新买一遍应该取菜品当前价格重新计算下单时的快照价已经过时了。这个接口虽然逻辑简单但它是逆向数据流转的典型例子正向是购物车生成订单反向是订单生成购物车。理解了这个双向流转后面做评价自动填充商品推荐之类的功能也会顺手很多。4. 订单状态流转校验每个操作都要先问现在能不能做4.1 状态流转的整体规则盘点把所有订单操作的状态校验汇总一张表写代码的时候对照着看不容易漏操作操作方操作前状态操作后状态核心校验点提交订单用户无1待付款购物车不能为空模拟支付用户1待付款2待接单订单归属正确、状态为1取消订单用户1或26已取消状态合法、退款模拟接单商家2待接单3已接单状态必须为2拒单商家2待接单6已取消状态必须为2派送商家3已接单4派送中状态必须为3完成商家4派送中5已完成状态必须为4我从这张表里提炼一条编程原则状态校验要写在业务逻辑的最前面越早拦下越省事。我见过有同学先做退款再判断状态结果状态不合法但钱已经退了改BUG改了半天。校验前置是一个好习惯。4.2 商家端接单/拒单的实现细节商家端接单的逻辑最简单的写法是根据订单ID查出订单判断状态是否为2待接单是则改为3已接单否则抛异常。拒单同理状态必须是2才能拒拒了之后订单变成6已取消同时要处理退款。拒单比接单多做一步因为用户付了钱商家说做不了钱要退回去。这个和用户取消待接单订单的退款逻辑是一样的可以抽一个公共方法出来复用。实际开发中商家端接口还有个隐含要求接口要返回订单ID和状态给前端前端要根据结果刷新列表让用户实时看到自己的订单被接或被拒。4.3 并发场景下的状态覆盖一个极端但真实的隐患状态校验的问题在串行请求下很好处理但真实场景是并发。举一个极端例子用户A和商家B同时操作同一张订单用户A点取消商家B点接单。两个请求同时进来都先查到了状态2用户A把状态改成6商家B把状态改成3谁后写谁赢。如果商家B后写这张已取消的订单变成了已接单商家照常出餐用户却已经收到了退款白吃一顿。这个问题在苍穹外卖的项目里大概率不会被测试出来因为并发量太小。但如果你以后做真实项目处理方式是在SQL里加状态条件比如UPDATE orders SET status 3 WHERE id #{id} AND status 2然后检查受影响的行数如果为0说明状态已经被别人改了直接抛异常提示订单状态已变更请刷新后再操作。这个方案叫乐观锁/条件更新比先查再改的查改分离安全得多。day09阶段先用传统的查改分离把逻辑跑通没问题但你要知道这个隐患存在。5. 用户端历史订单与订单详情查询多表查询与页面组装5.1 历史订单分页查询的业务含义用户端我的订单页面需要展示历史订单列表接口通常是/user/order/history参数有页码、每页条数、订单状态可选用于待付款已完成等标签页筛选。这个接口的SQL不复杂但查询条件要根据前端行为仔细设计。用户进入全部标签页时不传状态进入待付款标签页时传status1。分页查询用PageHelper插件或者手写LIMIT都可以苍穹外卖里用的是PageHelper注意查询后返回的Page对象要转成业务VO再返回。订单列表的每一条记录要展示的东西有不少订单号、金额、下单时间、状态文本、订单里的菜品缩略图列表。菜品列表来自订单明细表一单多条明细如果逐条查询会触发N1问题。合理做法是先查出当前页的订单ID集合再用IN查询一次性把所有明细查出来在内存里按订单ID分组组装到VO里。代码不多但对性能的影响是实打实的。5.2 订单详情主表明细表的联合组装订单详情接口/user/order/details/{id}返回单个订单的完整信息订单基本信息编号、金额、状态、时间、备注、收货人信息姓名、电话、地址、明细列表菜品名、图片、数量、小计。实现分两步查订单主表查该订单的所有明细。然后组装成OrderVO返回。这个VO的字段设计有讲究前端页面上要显示全部菜品名称拼接成的文本所以VO里除了明细List还可以加一个orderDishes字符串字段格式比如鱼香肉丝x1宫保鸡丁x2米饭x3方便列表页直接展示。这个字段在列表和详情里都要用。苍穹外卖还要求订单详情里展示预计送达时间、实际送达时间等时间信息。实际送达时间在商家点完成时设置查询时可能为null前端要处理空值。这类查询时可能为null的字段在写VO转DTO时要特别留意别直接把null塞给前端。5.3 为什么建议把状态数字转成状态文本放在Service层订单表里存的是状态数字1~6前端需要展示待付款待接单这种中文文本。在哪里转换苍穹外卖的做法是在Service层组装VO时就转好定义一个静态方法或者利用枚举order.getStatus() 2 时给VO的statusText赋值为待接单。前端拿到的是纯展示数据不用自己做状态字典映射。选择在Service层转而不是前端转核心原因是保持前后端契约稳定。前端不用知道1代表什么、2代表什么只要展示后端给的状态文本就行。将来如果要增加状态枚举值只改后端前端页面不动。这种后端出视图模型的思路在中小型项目中非常实用。6. 商家端订单管理查询条件、订单详情与状态操作6.1 商家端订单搜索的查询条件组合商家端管理页要支持按条件搜索订单常见的筛选维度有订单号精确或模糊、手机号精确、订单状态枚举单选、下单时间范围起止日期。这些都是可选条件所以SQL要用动态拼接的方式Mapper里用if标签判断每个参数是否为空。商家端列表的展示内容和用户端不同商家更关心今天接了多少单还有多少待处理所以列表通常按下单时间倒序排列状态筛选默认显示待接单的订单提醒商家去处理。分页逻辑和用户端一致但查出来的订单要关联显示订单号、下单时间、菜品总览、金额、状态、操作按钮接单/拒单/派送/完成依据状态动态显示。6.2 商家端订单详情的字段差异商家端查看订单详情除了常规的订单信息之外还关心顾客信息下单用户的ID、手机号、配送信息、备注、支付时间、拒单原因如果有。所以商家端详情的VO比用户端的VO多几个字段。在实现上商家端详情查询用订单ID直接查主表再用主表的user_id关联用户表补全用户手机号。注意一个细节用户手机号在订单表里其实已经冗余了收货人手机号但那是收货人的不一定是下单人的所以还要单独关联用户表查。6.3 商家端状态操作的统一入口设计商家端接单、拒单、派送、完成四个操作代码结构差不多。苍穹外卖在Controller里分别写四个接口Service层各自实现。我在写的时候习惯再提炼一层写一个OrderServiceImpl内部的私有方法入参是订单ID和目标状态执行查订单→校验当前状态→更新状态→执行附加操作如退款、设置时间的公共流程四个公开方法分别调这个私有方法并传入不同的状态值。这样后面加取消订单之类的操作只需要新增一个方法调用公共流程即可。这种提炼在day09阶段不是必须的但能帮你养成把公共逻辑下沉的习惯。公共流程里的状态校验规则可以维护在一个Map或者枚举里比如接单允许的前置状态是2派送允许的前置状态是3这样状态机规则的增删改都集中在一处比以前把校验散落在四个方法里好维护得多。7. 定时任务处理超时订单Spring Task在订单模块的落地7.1 超时未支付订单为什么要单独处理用户提交订单后不付款这个订单会一直停留在待付款状态。如果不处理积累多了会占用库存如果做了库存扣减、产生脏数据历史订单里全是僵尸单、影响商家的待处理列表用户的下单量虚高。苍穹外卖的处理策略是用户下单后超过15分钟未支付自动取消订单。这个需求天然适合定时任务每隔一段时间扫描一次订单表找到所有待付款状态下单时间超过15分钟的订单批量改成已取消状态。定时任务实现技术用的是Spring Task加EnableScheduling开启在任务方法上加Scheduled注解定义执行周期。这里的关键点是cron表达式怎么定下面细说。7.2 cron表达式与任务实现每日定时任务在苍穹外卖里开在配置类上用cron表达式控制频率。每1分钟执行一次的表达式是0 0/1 * * * ?或者0 */1 * * * ?。任务方法是真正的业务逻辑Scheduled(cron 0 0/1 * * * ?) public void processTimeoutOrder() { LocalDateTime timeoutTime LocalDateTime.now().minusMinutes(15); ListOrders ordersList orderMapper.getByStatusAndOrderTime(Orders.PENDING_PAYMENT, timeoutTime); if (ordersList ! null ordersList.size() 0) { for (Orders order : ordersList) { order.setStatus(Orders.CANCELLED); order.setCancelReason(超时未支付系统自动取消); order.setCancelTime(LocalDateTime.now()); orderMapper.update(order); } } }这段代码的逻辑是先算出15分钟前的时间点查出所有状态待付款 且 下单时间早于这个时间点的订单逐条更新为已取消。查询条件用status 1 AND order_time timeoutTime一次性把符合条件的订单全捞出来再批量更新。为什么不直接在SQL里一条UPDATE完成比如UPDATE orders SET status 6 WHERE status 1 AND order_time timeoutTime。这个思路其实是更高效的但苍穹外卖选择了先查再改的方式主要是为了给取消订单补充取消原因、取消时间这些字段并且每条订单可以单独记录处理日志。如果以后要接消息推送通知用户你的订单已超时取消这个循环里就是天然的推送点。真实项目中可以折中先查出ID集合再做批量UPDATE性能和处理灵活性兼顾。7.3 定时任务的幂等与扫表范围注意定时任务最怕重复执行。如果上一次任务跑了1分钟还没跑完下一次触发又开始了可能出现同一条订单被两条任务线程同时处理重复更新状态。Spring Task默认是单线程串行执行一般来说不会出现同一个方法并发执行的情况但在集群部署时多个实例各自跑定时任务就会重复处理同一批订单。幂等方案有两个方向一是状态条件更新UPDATE语句带AND status 1第二次执行时状态已经不是1更新行数为0自然跳过二是引入分布式锁让同一时刻只有一个实例执行任务。对于学习项目加状态条件就够了但你要知道集群场景下定时任务不是随便写的。另外扫表的范围也要注意每次任务要扫描全部订单表吗最好通过索引和查询条件缩小范围只查status1的订单。如果订单表数据量大了这种定时扫表可能导致全表扫描生产环境通常会结合分库分表或者时间分区来处理day09先把逻辑跑通即可。8. 实操中的五个经典报错与排查思路8.1 事务注解不生效订单明细丢了但主表还在场景下单后查订单主表有记录但订单明细表查不到。排查下来是Service方法里对异常做了catch处理完直接return异常被吞掉事务感知不到于是支配好的回滚没触发。解决Transactional默认只对RuntimeException回滚。下单逻辑里如果catch了Exception要么在catch块里重新抛出要么把事务扩大让内部不再catch。这个问题的本质是事务边界和异常处理边界没有对齐。8.2 状态重复提交连续点两次支付按钮场景用户在前端快速点了两次确认支付后端收到两个请求第一次把状态改成2第二次又执行了一遍查订单→改状态下单时间、支付时间被覆盖成第二次的值。解决在支付方法里加状态校验只有status1才能执行支付。第二次请求进来时查到status已经是2直接抛业务异常订单已支付请勿重复操作。这个和4.3说的SQL条件更新是同一个思路低并发下状态校验就够高并发下用条件更新更稳。8.3 再来一单口味丢失明细表里根本没有口味字段场景做再来一单时把订单明细转成购物车记录发现购物车表的口味字段为空。查数据库发现订单明细表压根没有存口味下单的时候就没写进去。解决回到提交订单的逻辑插入明细时把购物车里的口味字段一并拷贝到明细表再来一单时再把口味拷贝回购物车。如果明细表字段设计时漏了口味就需要加字段或者干脆在再来一单时不再依赖明细表的口味而是通过菜品ID重新查当前口味。总之做逆向操作之前先检查正向操作存了哪些字段。8.4 商家端接单后用户端还是显示待接单缓存还是没刷新场景商家接单成功后用户端刷新页面订单状态还是待接单。排查后发现不是状态没改而是查询接口里有缓存或者前端页面用了本地缓存的状态值没有重新拉取。解决先排除缓存再看接口返回最后查前端逻辑。苍穹外卖里没有引入Redis所以多半是前端页面没有重新加载硬刷新即可。但如果你在项目里加了Redis缓存订单列表接单后要记得删除对应的缓存key这是一个实际生产中很常见的缓存失效问题。8.5 定时任务把还在付款中的订单误取消时间边界差了几分钟场景用户正打算付款订单被系统自动取消了原因是超时未支付。一看下单时间还差1分钟才到15分钟。解决时间比较要看两个值——当前时间减去15分钟和订单下单时间。如果任务在14分59秒跑订单刚好处在临界点取消操作和用户的支付操作并发确实可能发生。解决办法是把超时阈值稍微放宽比如15分钟改成16分钟或者在取消前再校验一次订单状态并校验支付时间是否为空。真实系统里超时时间和支付回调是有时间误差的阈值稍放宽是合理做法。9. 给学习者的三点建议第一day09的订单模块是整个苍穹外卖项目的分水岭。前面几天的增删改查是会写到订单这里要会想——状态机怎么设计、事务怎么控制、权限怎么校验、定时任务怎么保证幂等。把这些想明白了不仅这个项目做完以后做任何带订单的业务系统骨架都是通的。第二写代码前先把状态流转图画出来。不用画得多复杂就把用户端操作和商家端操作两条线分别列出来标注每个操作的前置状态和后置状态。这张图就是你写校验逻辑的清单对照着写不容易漏。我用这个方法带过几个同学写订单模块出错率明显低于直接埋头写代码的。第三学有余力的话把第4节提到的SQL条件更新作为进阶练习自己实现一遍把接单改成UPDATE orders SET status 3 WHERE id ? AND status 2然后判断返回行数。这一小段代码一旦吃透你对于并发安全的理解会比背十遍八股文都有用。