☰
苍穹外卖day08用户下单模块实战:状态机、事务与支付回调
2026/10/1 3:36:55 网站建设 项目流程

小程序点完购物车,最关键的“用户下单”来了。做苍穹外卖项目,day08应该是大多数人第一次认真接触“订单”这个概念:从生成购物车数据,到校验、算钱、落库,再到支付回调、状态流转。这不仅是苍穹外卖业务闭环最重要的一环,也是面试时最容易拿来问你“有没有考虑过并发、事务、超时”的部分。

这篇文章就围绕day08的用户下单模块,把设计思路、表结构、核心代码、状态流转,以及几个我实际调试时踩过的坑完整理一遍。适合正在做苍穹外卖项目、想搞懂下单链路的人,也适合准备用这个项目去面试、想讲清楚订单设计的人。

1. 下单业务的整体设计与状态机

1.1 订单状态为什么必须由数字定义

苍穹外卖的订单状态,在代码里就是一张orders表里的status字段,取值 1 到 6,分别对应待付款、待接单、已接单、派送中、已完成、已取消。

我第一次看的时候觉得这个设计很简单,甚至有点“土”,但真正动手写了才知道,这六个数字就是整个订单模块的骨架。所有接口,甭管是用户端的取消订单、商家端的接单、还是管理端的派送,实质都是在做同一件事:判断当前状态允不允许跳转到下一个状态,然后执行对应的更新逻辑。

拿“取消订单”来说。如果订单状态是 1(待付款),用户可以直接取消;如果已经变成 2(待接单),能不能取消就看商家有没有接单,在苍穹外卖中没有做强制限制,但实际业务里大概率就不允许了。如果你不给状态做任何控制,前端想传什么就传什么,那订单状态就乱了,月底对账时你会哭着改数据。

所以下单模块设计的第一步,不是急着写代码,而是先把状态流转图画清楚。我在动手前画了一张状态图,大概就是:

  • 1 待付款:支付成功 -> 2 待接单;取消 -> 6 已取消
  • 2 待接单:商家接单 -> 3 已接单;用户取消 -> 6 已取消
  • 3 已接单:开始配送 -> 4 派送中
  • 4 派送中:送达 -> 5 已完成
  • 5 已完成:没有后续流转
  • 6 已取消:终态

这张图看起来简单,但它决定了你 Service 层的方法怎么划分。比如用户端cancelOrder里必须要判断status == 1 || status == 2,商家端confirmOrder必须判断status == 2。把状态校验写在每个操作方法开头,是下单模块最基础也最必要的防御。

1.2 购物车到底是个什么结构

购物车是下单的“原料库”。苍穹外卖的购物车表shopping_cart设计得很直接:一个用户可以有多个购物车条目,每个条目要么是一个单品菜(dish_id),要么是一个套餐(setmeal_id),再带上数量、口味和金额。

有两点值得注意。

第一,购物车表没有用单独的“购物车ID”作为业务维度,而是以user_id作为逻辑归属。也就是说,用户每次登录后看到的购物车,就是SELECT * FROM shopping_cart WHERE user_id = ?查出来的结果集。这个设计对单体项目来说完全够用,不用刻意上 Redis 或者搞购物车聚合,反而显得干净。

第二,同一个菜如果选了不同口味,会被拆成两条记录。比如“宫保鸡丁”选了“微辣”和“中辣”,这就是两个购物车条目。判断“加购时购物车里是否已经有这个菜”,不能只看dish_id,必须连同口味字符串一起判断。我在做加购接口时就是dish_id + dish_flavor拼起来作为唯一性判断,这样用户点“去结算”时,才能把不同口味的菜品分开算钱。

下单时的数据来源,就是这个购物车列表。苍穹外卖没有做复杂的购物车勾选、批量下单,直接把全部购物车条目转成订单明细,这个简化对教学项目来说没问题,也符合大多数人对“点餐”流程的理解。

2. 订单表结构与金额计算的设计细节

2.1 为什么订单要拆成主表和明细表

苍穹外卖的订单相关表有两张:orders和order_detail。第一张存订单的总体信息,比如订单号、总金额、状态、下单时间;第二张存订单里的每一条菜品快照,比如菜名、口味、数量、小计金额。

为什么要拆?原因很简单:场景不同。

orders表服务于订单管理,一个订单的查询、接单、派送、统计,都在这一行数据上做操作。order_detail表服务于订单账单展示,用户查看“我的订单”时,需要看到这个订单里都有什么菜。如果全塞到一张表里,要么订单主表冗余大量重复信息,要么接口查询时还得解析 JSON 字符串,写起来啰嗦,查起来也慢。

更关键的是,下单时的菜品明细必须是“快照”,而不是“引用”。什么意思?就是order_detail里存的菜名、价格、口味,是在下单那一刻从菜品表里拷过来的,下单之后你再改菜品价格,已经产生的订单是不会跟着变的。如果明细表只存dish_id,展示时再去查菜品表,那用户过了三个月看历史订单,看到的价格就是当初下单时的价格,但菜名可能已经被商家改掉了。这不合理,所以明细表必须把关键字段冗余下来,牺牲一点存储,换取历史的真实。

2.2 金额计算有哪些隐藏细节

下单的核心是算钱。苍穹外卖的金额计算逻辑比较直接,把购物车里的每条数据遍历一遍,统计总数、总金额,再有一个amount字段记录实付金额。但这里有几个细节,初学者特别容易翻车。

第一个是套餐金额的计算。购物车里如果是套餐(setmeal_id不为空),金额就是套餐直接的价格;如果是单品菜,金额还要考虑口味是否加价。苍穹外卖的菜品价格本身就存在菜品表里,口味不加价,所以实现时可以简化,但计算逻辑要区分开来写,不能一张表通吃。

第二个是金额精度。金额相关的字段,数据库里用decimal,Java 侧对应BigDecimal。千万不要用double和float,下单金额差个 0.01,后面支付对账对不上,真的很痛苦。遍历购物车做累加时,用BigDecimal.add(),别图省事用+。

第三个是优惠金额。苍穹外卖的 day08 里没有做优惠券、满减,订单的amount就是购物车菜品总额。但表结构里预留了discount_amount之类的字段(具体看版本)。我个人建议,即使不做优惠功能,表里也保留“总金额”和“实付金额”两个概念,因为后续需求一定会加。先拆开,后面扩展时改起来不痛苦。

我在代码里还加了一个保护:如果购物车是空的,直接抛业务异常。这个检查看起来多余,但真的会有人在没有加购任何菜品的情况下,直接调用下单接口。如果你不拦,后面遍历一个空列表,前端拿到一个莫名其妙的空订单,用户一脸懵,你排查也得半天。

3. 用户下单核心流程的代码实现

3.1 下单流程的步骤拆解

苍穹外卖的下单入口是用户端的/user/order/submit接口,Controller 很薄,真正核心在 Service 层。我把整个流程拆成了下面几个步骤,这也是我最终代码里 Service 方法的主体结构:

  1. 从 ThreadLocal 里取出当前登录用户 ID;
  2. 根据用户 ID 查询购物车列表;
  3. 购物车为空则抛出业务异常;
  4. 遍历购物车,累积总金额,同时构造order_detail明细列表;
  5. 构造orders主表对象,生成订单号,填充状态、下单时间等字段;
  6. 批量插入订单明细;
  7. 插入订单主表(或先插主表再插明细,取决于主外键关系);
  8. 清空当前用户的购物车。

这个顺序看起来平平无奇,但每一步都有讲究。比如先查购物车再插入订单,是为了确保下单数据是“用户当前看到的”数据。如果你先插入订单再查购物车,期间用户又加了菜,两个动作之间就出现不一致。

再比如清空购物车必须放在最后。如果订单已经插入成功,但最后清购物车失败,用户的购物车里还留着已下单的菜,用户再点一次下单,就会出现重复订单。所以清购物车这一步要放在事务的最后,确保前面所有写操作都成功了,才做清理。如果清理失败,事务回滚,订单也不会落库,两边不冲突。

3.2 订单号与事务控制的两个关键点

生成订单号是下单模块一个很容易被忽略的功能点。苍穹外卖里订单号并不来自数据库自增 ID,而是一个独立生成的字符串,通常是时间戳加随机数,或者基于雪花算法。为什么不用自增主键?因为订单号要面向用户、面向支付渠道。用户看到“订单号 1024”会觉得很不专业,支付回调时也要拿订单号作为业务查询条件,自增 ID 暴露了系统的日单量,这是业务上不希望出现的情况。

我当时用的是简单的System.currentTimeMillis()加随机数组合,再加一个用户 ID 后缀,保证唯一性。如果你不想在这个环节上花太多时间,用 Hutool 的IdUtil.getSnowflakeNextIdStr()也行,实战项目里用雪花算法的场景很多,面试时还能借此聊两句分布式 ID。

事务这一块,苍穹外卖在 Service 方法上加了@Transactional。下单涉及四张表的写操作:查购物车、插订单主表、插订单明细、清购物车,其中只要任何一步失败,都不应该留下半截数据——比如订单有了但明细没插进去,或者菜没扣掉但订单生成了。所以整个下单方法必须在一个事务里。

这里有一个容易踩的坑:@Transactional默认只在抛出RuntimeException时回滚。如果你的业务异常是自定义异常且没有继承RuntimeException,事务不会回滚。我当时写的BusinessException是继承RuntimeException的,所以问题没有暴露,但如果你的异常体系里用的是 checked exception,一定要在@Transactional里加rollbackFor = Exception.class。

3.3 ThreadLocal 用户信息的获取

苍穹外卖项目用 ThreadLocal 来传递当前登录用户信息,这是一个非常经典的教学点。用户登录成功后,拦截器会把用户 ID 塞进 ThreadLocal;下单时,Service 里通过BaseContext.getCurrentId()拿到用户 ID。

这个设计的好处很明显,不用在每个接口的参数里都带上 user ID,也不用从 token 里解析用户信息,代码干净。但 ThreadLocal 有个大坑:线程复用导致用户信息串号。Tomcat 的工作线程是被复用的,如果请求结束后没有清理 ThreadLocal,下个请求可能会读到上一个请求的用户数据。

所以苍穹外卖在拦截器的afterCompletion方法里必须调用BaseContext.removeCurrentId(),把 ThreadLocal 清掉。如果你在本地调试时发现 A 用户下单,订单却挂在 B 用户名下,八成就是这个清理没做干净。

4. 模拟支付与订单状态的流转逻辑

4.1 支付模块如何本土化实现

真正的微信支付需要商户号、证书、回调域名,教学环境通常不具备条件。苍穹外卖这个阶段最常见的做法是“模拟支付”:在前端提供一个支付按钮,点击后直接调用后端的一个模拟支付接口,把订单状态从待付款改成待接单。

这个方案虽然“假”,但业务逻辑是真的。模拟支付接口要做的事:校验订单存在、校验订单状态是 1(待付款)、更新状态为 2(待接单)、记录支付时间。这些步骤跟真实支付完全没有区别,未来接真实支付时,只需要把“模拟支付”替换为“调用微信统一下单接口 + 接收支付回调”,状态流转逻辑完全复用。

我做这个模块时加了一个小细节:模拟支付接口必须做“重复请求”防护。用户前端如果卡了一下,连续点了几次支付按钮,后端收到两次请求,第一次已经把状态改为 2,第二次再来时发现当前状态不是 1,应该直接提示“订单已支付”,而不是再次执行状态更新,否则会出现重复支付记录。

4.2 支付回调的幂等处理思路

真实支付场景里,支付回调是支付平台主动请求你的后端接口,通知你“这笔订单支付成功了”。这种回调有一个特点,一定会重试,可能同一笔订单回调五六次。如果你的回调处理逻辑不幂等,订单状态被更新错乱,或者积分被重复添加,后果都很麻烦。

苍穹外卖的模拟支付虽然不需要处理回调,但表结构里有一个字段能体现这个设计思路,比如pay_status或订单状态本身。我建议在模拟支付接口里加一个判断:只有当订单状态是 1(待付款)时,才允许更新;如果订单已经是 2(待接单),直接返回成功,不做任何操作。这个写法就是“先查状态再更新”,一旦状态变了,重复请求就变成了空操作。这个方法在真实支付回调里同样适用,是幂等处理的经典实现。

4.3 超时订单怎么处理

用户下单后不支付,订单就会一直卡在待付款状态。苍穹外卖 day08 的交付内容里倒是不一定有定时关单,但你如果要拿这个项目去面试,建议手动把这个功能补上,因为“订单超时未支付自动取消”几乎是大厂必问的场景。

实现思路不复杂。第一种方案是定时任务,比如每分钟扫描一次订单表,把创建时间超过 15 分钟、状态仍为 1 的订单,统一改为 6(已取消),同时可以顺便恢复库存。第二种方案是延迟消息队列,下单后发一条延迟消息,15 分钟后消费,消费时再判断订单状态,如果还是待付款就取消。苍穹外卖是单体项目,用第一种方案就够,Spring 自带的@Scheduled就能实现。

补这个功能时有一点要注意:定时任务扫描全表随着数据量增大性能会越来越差,实际生产中一般会加一个索引,或者按时间分片扫描。苍穹外卖项目里数据量小,不用考虑,但面试时可以主动提一句“全表扫描在大数据量下会退化,实际场景会配合消息队列或者订单表的时间索引做优化”,这一句话就能比大多数只会背概念的人强。

5. 常见问题与排查技巧实录

5.1 事务不生效的几种情况

事务不生效,是下单模块最常见的故障来源。我调试时就遇到过一次:用户下单后,订单主表插进去了,但清购物车失败,回滚后订单居然还在。

排查原因,无非三种。

第一,方法被同类内部调用。submitOrder方法内部调用了同一个类里的clearCart方法,两个方法都加@Transactional,但 Spring 事务是基于 AOP 代理的,同类内部直接调用走的是this,不会经过代理,所以内部方法上的事务注解根本不起作用。

第二,异常被 catch 住了。Service 方法内部 try-catch 把所有异常吞掉,只返回一个 false 或者 null,事务框架根本感知不到异常,自然不会回滚。我在下单代码里没有做 catch,让异常直接抛出,才能触发回滚。

第三,事务方法不是 public。Spring 默认只对 public 方法代理事务,如果你把下单方法写成 private,会导致事务注解完全失效。

5.2 购物车数据查不到或超卖

购物车查不到,首先要检查用户 ID 是否为空。如果你用的是 ThreadLocal,先确认拦截器有没有拦截到/user/order/submit这个路径,Token 有没有正常解析。苍穹外卖的拦截器配置里,如果路径匹配规则写错了,请求根本不会经过拦截器,ThreadLocal 里自然没有用户数据,下单接口拿到的就是一个 null 用户 ID,查询出来的购物车列表当然是空的。

关于超卖,苍穹外卖本身没有做库存扣减,但你在项目里如果有“点餐成功后减少菜品库存”的需求,就一定要考虑并发问题。最简单的方案是 SQL 层做条件更新,比如UPDATE dish SET stock = stock - 1 WHERE id = ? AND stock > 0,利用数据库行锁保证同一菜品不会被超卖;再加一个版本号字段做乐观锁,也能解决。不要在代码里先查库存再判断够不够,再执行扣减,那两步之间一定有并发窗口,很容易超卖。

5.3 本地联调时的几个实用技巧

本地下单联调,我一般会在OrderServiceImpl.submitOrder方法入口和清空购物车之前各打一个断点。入口断点用来确认用户 ID 和购物车数据是否正确;清空购物车前的断点用来确认订单主表和明细表的数据是否正确落库。这两个位置是下单链路最关键的数据校验点。

因为苍穹外卖是一个前后端分离项目,你还要注意前端请求的 JSON 结构。前端提交的OrdersSubmitDTO里有remark(备注)、addressBookId(地址簿 ID)等字段,但购物车里的菜品列表是不用前端传的,后端通过用户 ID 自己查。如果你发现下单后订单里没有菜品,先检查是不是前端私自传了菜品列表,而后端又恰好用前端传的数据覆盖了数据库查询结果。正确做法是后端完全信任自己查出来的购物车数据,前端传的菜品列表一律忽略。

还有一个常被忽略的问题是时区。订单表的order_time字段如果在插入时用了new Date(),数据库存的时间可能与你本地时间相差 8 小时。这个跟数据库连接的serverTimezone配置有关系,如果你用 MySQL 8,连接串里最好显式写上serverTimezone=Asia/Shanghai。不然用户看到的订单时间都是“昨天”,查日志时很崩溃。

6. 实操中的心得体会

6.1 下单模块是整个苍穹外卖项目的“压舱石”

我带这个项目时反复跟学员强调,day08 的下单功能是整个项目的“压舱石”。前面几天的员工管理、分类管理、菜品管理,本质都是 CRUD,谁都能写;从用户下单开始,才真正进入“业务流程设计”的层次。你要考虑状态流转、考虑事务、考虑数据一致性、考虑重复请求。这些能力,才是面试官愿意为你买单的东西。

所以我不建议把这个模块写完就跑。你至少要能做到:不看代码,把下单的七个步骤按顺序默写出来;不看表结构,把orders和order_detail的主要字段说出来;不看 Controller,把下单接口的请求参数和返回结果描述清楚。能做到这三件事,面试官问到你,你就不会慌。

6.2 一个值得扩展的小功能:下单前校验菜品是否下架

苍穹外卖在这个阶段没有做“下单前校验菜品是否在售”的功能,但我强烈建议你补一个,因为这是一个学生项目里很少见、但面试官很喜欢的细节。

做法也不难:遍历购物车明细时,根据dish_id或setmeal_id查询菜品/套餐的状态(status 字段),如果有任何一个菜品已经停售,直接抛业务异常,告知用户“XXX 已下架,请重新选择”。逻辑只需要几行代码,但体现了你真的思考过“购物车里的菜可能已经下架了”这个现实场景。

我在实际项目中经常遇到这种情况:用户把菜加进购物车,过了半小时才下单,商家已经把菜下架了。如果没有校验,用户下单成功,商家却没法出餐,最后只能人工取消订单。与其让用户经历这一遭,不如在下单入口就拦住他。这个改动小,但对用户体验的提升非常明显。

6.3 最后提一个代码规范的小建议

下单相关的 DTO、VO、实体类非常多,代码里很容易出现命名混乱。我建议你遵循一套约定:前端传给后端的对象叫 DTO,后端返回给前端的叫 VO,数据库实体叫 Entity。OrdersSubmitDTO是前端传过来的,OrderSubmitVO是后端返回给用户的(包含订单 ID、订单号、支付金额等)。开发时严格按照这个约定走,项目后期会少很多沟通成本。

另外,下单 Service 方法别写得太长。我当时把“遍历购物车构造明细”和“生成订单主表数据”拆成了两个私有方法,主流程看起来非常清爽。虽然对功能没有影响,但等你过两周再回来看这段代码时,你会感谢自己当时没有偷懒。

苍穹外卖的 day08 用户下单,就是这个项目的分水岭。动手写之前多想想状态机,写的时候注意事务和金额精度,写完后再上线一个“订单超时取消”的小功能,这个模块就能拿去面试了。踩过坑的地方我都写在上面了,照着敲,希望你能少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询