订单模块开发实战:外卖系统状态流转与超时取消实现

发布时间:2026/10/23 6:51:46
订单模块开发实战:外卖系统状态流转与超时取消实现 1. 今日任务拆解为什么第十一天才轮到订单模块“苍穹外卖”这个项目做到第十一天其实已经过了最兴奋的阶段。前后端基础架构、员工登录、菜品分类、购物车这些模块早就跑通了剩下的都是硬骨头。今天的工作重心非常明确订单模块。之所以把订单模块安排在第十一天不是因为前面的进度慢了而是因为它太核心了。一个外卖系统的订单模块牵扯的不只是数据库里几张表那么简单它同时连接着购物车、用户地址、菜品库存、支付回调、商家接单、骑手派送这一整条链路。过早动手前面一些基础模块的接口规范还没稳定后面改起来就是连环爆炸。等购物车、地址簿、菜品上下架这些前置功能都稳定了再碰订单心里才有底。今天计划做的事情也很具体用户端提交订单的完整接口包括购物车数据校验、订单金额计算、库存预扣。管理端订单列表查询支持多条件筛选和分页。商家接单、拒单、派送、完成这几个状态流转操作。超时未支付订单的自动取消以及相关的定时任务处理。看起来就四条但每一条展开都是能写一整天的工作量。我自己在动手前先写了一页TODO清单把每个大项拆成小项比如“提交订单”就拆成参数校验、购物车读取、金额计算、订单表写入、订单明细批量插入、清空购物车、返回订单号这七个步骤。拆完之后发现今天能把这些做完就已经非常饱和了所以决定暂时不碰“订单退款”和“骑手端实时定位”这两个延伸功能留给后面再说。注意项目日记最忌讳的就是贪多。第十一天不是把订单模块所有功能都做完而是把主链路做扎实。边界清晰了开发效率反而更高。2. 用户端下单一条接口背后的事务边界2.1 下单接口的核心逻辑顺序用户端下单这个接口路径我定的是POST /user/order/submit传入的数据是地址簿id、备注和购物车数据这里还有一个细节购物车数据其实可以不传因为后端知道当前登录用户是谁直接查数据库里的购物车表就行。我选择让前端把购物车数据也带过来主要是为了做一次对账防止用户下单的一瞬间购物车被人改过或者某些菜品已经下架导致价格错位。接口内部的核心逻辑顺序是这样的// 伪代码示意实际项目里会拆成多个方法 public Order submitOrder(OrderSubmitDTO dto) { // 1. 参数基础校验地址簿必须存在、购物车不能为空 // 2. 从数据库查出当前用户的购物车列表 // 3. 遍历购物车核对菜品是否已下架、计算总金额 // 4. 构造订单主表记录状态设为待付款 // 5. 批量插入订单明细表 // 6. 清空当前用户的购物车 // 7. 返回订单号前端拿着这个订单号去调支付 }这一步最关键的不是写代码的先后顺序而是事务边界。下单、写明细、清购物车这三个动作必须在一个事务里完成。如果订单写进去了但清购物车失败用户看到的购物车还是原样再次下单就会产生重复订单。所以我在submitOrder方法上加了Transactional让异常发生时全部回滚。2.2 金额计算的坑前端算的数不能直接信关于金额计算我多说两句。很多初学的小伙伴习惯直接拿前端传过来的总金额存库这是个大坑。前端展示的价格只能作为参考后端必须根据数据库里的菜品原价重新算一遍。我在代码里是这样做的// 遍历购物车逐项计算金额 BigDecimal totalAmount new BigDecimal(0.00); for (ShoppingCart cart : cartList) { // 查询当前数据库中菜品的最新价格 Dish dish dishMapper.getById(cart.getDishId()); if (dish null || dish.getStatus() 0) { throw new BusinessException(菜品已下架 cart.getName()); } // 不是直接用购物车里的金额而是用数据库里的最新价格 BigDecimal itemAmount dish.getPrice().multiply(new BigDecimal(cart.getNumber())); totalAmount totalAmount.add(itemAmount); }这里有个日常开发里才会注意到的细节购物车表里其实也存了一份amount字段那这个字段有什么用我的做法是购物车里的amount只用来在购物车页面做展示真正下单的时候必须重新查菜品价格。因为菜品可能在中途被商家改了价格你在购物车页面看到的金额是加进购物车那一刻的价格用户结账时应该以最新价格为准。另外BigDecimal一定要用构造方法传字符串new BigDecimal(0.01)这种用法会丢失精度外卖系统涉及真实账目一分钱的误差都不能有。我把所有金额计算都强制用BigDecimal禁止用double或者float这个约束我也写进了开发规范。2.3 订单号生成不能只靠数据库自增订单号这个细节很多人会忽略。直接用数据库主键自增ID当订单号简单但有几个问题一是订单号太长容易暴露当日订单量二是多表关联时需要回显处理起来麻烦。我采用的是时间戳 用户ID后四位 随机数的拼接方式String orderNumber String.valueOf(System.currentTimeMillis()) String.format(%04d, userId % 10000) String.format(%04d, new Random().nextInt(10000));这样生成的订单号是19位数字基本不会重复而且从订单号里能看出下单的大致时间和用户尾号排查问题的时候特别方便。实际项目中订单号通常会走专门的发号器比如雪花算法但在“苍穹外卖”这个项目里我的方案完全够用不引入额外中间件减少系统复杂度。下单完成后我顺手做了一个小优化把订单号返回给前端的同时把订单状态也一起返回。这样前端拿到结果后直接根据状态判断是跳去支付还是提示用户订单异常少了一次额外的查询请求。3. 管理端订单管理状态流转比增删改查难得多3.1 订单列表查不出来多条件分页的细节管理端订单列表是今天花了最多时间调试的部分。GET /admin/order/page这个接口要支持按订单号搜索、按状态过滤、按下单时间范围查询还要做分页。前端传参的格式是{ page: 1, pageSize: 10, number: 202501011200001234, status: 2, beginTime: 2025-01-01 00:00:00, endTime: 2025-01-01 23:59:59 }后端用PageHelper做分页然后拼动态SQL。这里有一个经常踩的坑PageHelper必须在查询方法的第一行调用如果你先执行了一些其他查询分页就会串页。我的习惯是把分页参数封装成PageQueryDTO然后在Service层第一步就调用PageHelper.startPage()后续的select才是真正被分页拦截的那条SQL。多条件筛选的Mapper写法我建议用where标签配合if判断而不是手写一堆where 11。例子如下select idpageQuery resultTypecom.example.vo.OrderVO select * from orders where if testnumber ! null and number ! and number like concat(%, #{number}, %) /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 /select这里要注意的是gt;和lt;转义很多新手直接在XML里写在特定环境下会报错。另外order by order_time desc必不可少管理端列表不排序根本没法看新订单永远在最上面。3.2 接单和拒单状态校验比改状态本身重要订单状态在业务里是一个有限集合我定义在OrderStatus常量类里状态值含义说明1待付款用户已下单未支付2待接单已支付成功等待商家处理3已接单商家已接单正在制作4派送中骑手已取餐正在配送5已完成用户已确认收货或系统自动完成6已取消用户取消或超时未支付7退款中售后流程暂未实现预留状态位接单操作看起来就是一个update语句把状态从2改成3但有一个隐藏校验只有状态是2的订单才能被接单。如果用户已经申请取消或者订单已经被其他窗口接走了这时胡乱的接单操作就会造成状态错乱。我在Service层加了校验public void confirm(Long orderId) { Orders order orderMapper.getById(orderId); if (order null) { throw new BusinessException(订单不存在); } // 只有待接单状态才能执行接单操作 if (!order.getStatus().equals(OrderStatus.PENDING_CONFIRM)) { throw new BusinessException(当前订单状态无法接单); } order.setStatus(OrderStatus.CONFIRMED); orderMapper.updateStatus(order); }在真实的外卖场景里还要校验当前商家是否有权限操作这单。比如商家A不能接商家B的订单。但“苍穹外卖”项目里所有订单都是同一个店铺的所以这个校验我做了个注释标识留到后续扩展多店铺功能时再补上。拒单的场景稍微复杂一点拒单要填原因而且要区分是用户未付款取消还是商家拒单。如果用户已经付款了商家拒单就涉及退款这在项目里属于售后流程。第十一天我只做了“未付款状态下可以直接取消”和“已付款状态暂不支持商家拒单”这两个规则算是做了一个业务取舍也给后面的迭代留了口子。3.3 派送逻辑和时间线的记录派送这个操作其实就是在接单之后把状态改为4但我在这个环节额外加了两个字段的更新estimated_delivery_time预计送达时间和delivery_time实际派送时间。预计送达时间是根据当前时间加上30分钟算出来的实际派送时间取当前时间。这两个时间字段在订单详情页和用户端都很有用。用户端需要展示“预计xx:xx送达”来安抚等待情绪运营后台也需要知道哪些订单配送超时了。所以派送这个接口虽然逻辑简单但它触发的是两个字段的同时变更在update语句里要写全不能用只更新状态的SQL去碰这张表。管理端订单操作我统一用了乐观锁的思路虽然现在只有update ... where id ?但状态流转的并发问题在真实场景里一定会遇到我在代码注释里特别标注了这一点方便后面加上version字段时知道自己当初为什么预留了这个扩展点。4. 超时订单自动取消定时任务的选型与实践4.1 为什么不用Redis过期监听外卖订单创建之后用户如果一直不支付订单就会一直挂在“待付款”状态占着库存、影响商家的处理节奏。所以超时取消是必须做的。实现方式网上能搜到很多比较常见的有三种方案原理优点缺点定时轮询数据库每隔一段时间扫描订单表找到超时的订单取消掉实现简单可控性强存在延迟数据库压力略大Redis过期监听给订单Key设置过期时间过期时触发回调实时性高实现优雅Key过期事件不可靠消息可能丢失需要额外引入消息队列保障延时队列用RocketMQ等中间件实现延时消息精度高稳定可靠增加系统复杂度需要额外部署组件我最终选择了定时轮询数据库原因很直接“苍穹外卖”这个项目的并发量还没到需要上消息队列的程度。定时任务延迟30秒甚至1分钟用户完全可以接受而且轮询方案不依赖额外组件项目部署在任何一台机器上都能跑维护成本最低。4.2 Spring Task的极简实现Spring框架自带的Scheduled注解就够用了。我在项目的启动类上加了EnableScheduling然后单独建了一个OrderTask类来放定时任务Component Slf4j public class OrderTask { Autowired private OrderMapper orderMapper; /** * 每分钟执行一次处理超时未支付订单 * 例如15分钟未支付则自动取消 */ Scheduled(cron 0 0/1 * * * ?) public void processTimeoutOrder() { log.info(定时处理超时未支付订单); LocalDateTime now LocalDateTime.now(); LocalDateTime timeout now.minusMinutes(15); ListOrders timeoutOrders orderMapper.getByStatusAndOrderTime( OrderStatus.PENDING_PAYMENT, timeout); for (Orders order : timeoutOrders) { order.setStatus(OrderStatus.CANCELLED); order.setCancelReason(超时未支付系统自动取消); order.setCancelTime(now); orderMapper.updateStatus(order); log.info(自动取消了订单{}, order.getOrderNumber()); } } }这段代码有几个细节要注意。第一个是cron表达式0 0/1 * * * ?表示每分钟的第0秒执行一次?这个符号在秒和日字段里的语义不同第一次写的人经常搞混。第二个是查询条件里用order_time来筛选而不是create_time因为订单主表的order_time才是业务上下单的时间两个字段虽然通常相等但从业务含义上要分清。第三个是更新时只更新状态和取消原因不更新order_time这类创建时间字段避免把业务时间搞乱。4.3 轮询时间间隔的选择逻辑轮询时间是今天做的一个比较纠结的决定。Scheduled(cron 0 0/1 * * * ?)是每1分钟跑一次但实际超时检测的阈值是15分钟。也就是说某个订单可能在14分30秒时就已经超时了但最多要等到下一次轮询才能被取消实际最长的取消延迟是1分钟。这个延迟在真实业务里是不是能接受我认为可以。原因有两点一是外卖的下单支付场景用户要么付了要么不付了晚1分钟取消不会产生资损风险二是如果把轮询间隔改为每10秒一次数据库的压力会变成原来的6倍“苍穹外卖”项目数据量不大感知不明显但放在生产环境里这就是一个需要认真评估的成本。我是这样平衡的先保证业务正确再考虑体验细节。提示如果把项目做到生产级建议引入延迟消息队列方案用消息的定时发送来精确触发取消动作这样既兼顾了实时性也能削峰填谷避免定时任务扎堆执行。但在当前项目阶段Spring Task已经足够。5. 常见问题与排查实录5.1 金额算对了但前端一直显示未支付今天遇到的一个让我排查了很久的问题。用户下单接口返回成功前端跳转到支付页面但是一直显示“订单未支付”。后来查了下日志发现问题出在订单状态上。我下单后默认状态是1待付款但是前端判断“是否可以支付”的条件是状态必须等于1而我又在下单成功后立刻把购物车清空了导致前端再次查询订单详情时发现用户购物车为空前端代码里居然有一个“购物车为空则不让进支付页”的逻辑。这个坑挺隐蔽的属于前后端约定不一致。我的处理方案是下单接口的返回结果里把orderId和status都返回前端直接根据返回的orderId和status判断是否跳转支付而不是通过查询购物车状态来判断。顺手把前端那个不合理的逻辑也改掉了。5.2 事务不生效同一个类里方法自调用导致回滚失败下单的Service里我把校验、写订单、清购物车的逻辑拆成了几个private方法然后下单入口方法上加了Transactional。结果测试的时候故意让清购物车这一步抛异常发现订单还是写进数据库了回滚没有生效。排查之后确认是同一个类内部方法自调用导致Transactional失效。Spring的事务是通过AOP代理实现的类内部this.method()直接调的是原生对象的方法绕过了代理事务注解就失效了。解决办法有几个把被调用的方法拆到另一个Service类里由外部Bean调用。在入口方法里通过((OrderServiceImpl) AopContext.currentProxy())拿到代理对象再调用。更简单粗暴的做法把所有业务逻辑都写在入口方法中不拆那么多私有方法。我选了方案1把“写入订单明细”“清空购物车”抽到了OrderDetailService里这样入口方法的Transactional就能覆盖到跨Bean的调用了。5.3 定时任务跑了两遍部署多实例时任务重复执行这个其实不是第十一天才遇到的问题之前写定时任务的时候就有预感这次在超时取消的任务里彻底暴露了。我在本地起了一个实例调试过程中没有发现问题但是当我把项目部署到测试环境同时起了两个实例时定时任务会在两台机器上各执行一次。好在我的取消逻辑是先查询符合条件的订单再逐条更新状态而且每次更新都会带上前置状态校验所以第二次执行时订单状态已经不是“待付款”了更新不了不会造成严重的数据错乱。但日志里会出现大量的重复执行记录排查问题的时候特别乱。针对这个问题当前阶段我做了两件事第一在定时任务的逻辑入口加了一个分布式锁的注释占位后续引入Redis时会用setnx实现简单的分布式锁第二测试环境暂时只部署单实例先把功能验证稳定再考虑高可用。这是项目开发中的常见取舍很多问题不一定要立刻解决但要识别出它是问题记录下来排好优先级。6. 实际操作中的体会与今日小结按理说项目日记到这儿就该收工了但我还是想把今天最后半小时排查“支付回调验签”的经历记录下来因为这块特别容易让人掉头发。订单支付完成后微信支付会向我们的回调接口发送一个通知我们在回调里需要做两件事第一验签确认这个通知真的来自微信支付第二修改订单状态为“待接单”。我发现测试过程中回调接口被连续调用了两次第一次的状态修改成功了第二次来的已经是同一个订单号。由于订单状态已经变为2第二次回调进来时我必须保证它不会再把状态改成2一遍也不会触发其他副作用。解决办法是在更新的SQL里加上状态条件update orders set status 2, pay_status 1, pay_time #{payTime} where id #{orderId} and status 1这条SQL的含义是只有当前状态是1的订单才允许被改成2。重复回调时因为状态已经不是1了更新影响行数为0代码里根据影响行数就能判断这是重复通知直接返回成功即可。这个“乐观锁”的思想不仅用在支付回调上前面说的所有状态流转操作我都希望最终落到数据库的update语句时是带条件的而不只是应用层校验一遍就完事。很多没有实战经验的人会认为应用层已经判断过状态了为什么不相信它因为在并发场景下应用层的判断并不是原子操作。你查到了状态是1另一个线程可能在你查询之后、更新之前已经把状态改成了3。只有把状态判断落在SQL的where条件里才能保证判断和更新是一个原子操作。今天一整天下来代码量不算大但涉及的业务状态和事务边界确实让我重新理解了外卖系统“看似简单实则繁琐”的特点。尤其状态流转这部分系统的核心不在CRUD而在于每次流转都必须保证在正确的时机、由正确的角色、按正确的顺序完成。这需要开发时把业务规则想清楚也需要在数据层面加一道条件更新的保险。最后再分享一个小技巧我在调试订单状态相关接口的时候会在数据库里手动执行那条带条件的update语句模拟并发场景下的第二次调用。这样做的好处是能在没有并发测试工具的情况下快速验证条件更新是否真的靠得住。做业务开发很多时候不是功能跑通就结束而是在临界场景下还能保持数据正确这才是系统真正值钱的地方。明天的计划是把“订单催单”“再来一单”这两个用户端小功能做掉把今天没碰的退款流程梳理清楚。退款涉及的钱款安全问题比下单更敏感到时候我也会单独记一篇项目日记把退款校验的细节写出来。第十一天就到这里整体进度符合预期订单主链路的稳定性还有提升空间但已经具备了基本的业务完整性。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询