做苍穹外卖这个项目的时候很多同学一路顺风顺水走到用户端接口开发觉得CRUD不过如此。结果一到day09订单模块一上来整个人就懵了要同时处理购物车、地址簿、订单主表、订单明细表还要管状态流转、金额精度、事务边界、幂等回调。这已经不是“写接口”的问题了而是“怎么把业务链条安全地串起来”的问题。这篇把day09订单模块的核心链路、状态机设计、常见坑位一次说清楚正在跟课程的同学可以对照排查手头有类似外卖、电商订单需求的兄弟也能直接参考。之前day01到day08我们搞定了员工端、分类菜品、Redis缓存、Spring Cache用户端能看的页面基本都能返回数据了。但从day09开始系统才真正进入“交易闭环”用户下单、商家接单、派送、完成每一步都是状态变更每一步都可能出问题。所以这篇我不打算按部就班地把每个接口的代码贴一遍而是把订单模块中最容易翻车的几个环节拆开来讲下单事务怎么写才不会出现“购物车清了但订单没生成”的惨案状态机怎么设计才能避免订单从待付款直接跳成已完成再来一单这种看似简单的功能为什么藏着坑。内容会结合苍穹外卖项目里的实际表结构和业务逻辑偏实战偏排错。1. 订单模块的表结构设计先看懂数据怎么流转的1.1 订单主表、订单明细表和购物车表的关系day09的代码量其实不大但涉及的表的关联关系比前面所有模块都复杂。核心是orders订单主表和order_detail订单明细表加上我们前面已经做好的shopping_cart购物车表和address_book地址簿表。订单主表存的是“这一单是谁下的、送到哪、花了多少钱、现在什么状态”这类汇总信息。苍穹外卖里这张表字段非常多我挑几个容易忽视的说说number订单号这个是业务上要给人看的不能直接用自增ID后面细说。order_time、checkout_time、estimated_delivery_time下单时间、支付时间、预计送达时间三个时间字段别搞混。status订单状态1待付款、2待接单、3待派送、4派送中、5已完成、6已取消。pay_status支付状态0未支付、1已支付。amount、pack_amount、delivery_fee总金额、包装费、配送费。订单明细表存的是“这一单里到底买了哪几样东西”。核心字段就几个order_id、dish_id/setmeal_id、dish_name、dish_image、number、amount。这里有个很多人不理解的细节为什么明细表里已经有dish_id了还要冗余一份dish_name和dish_image因为菜品表和套餐表的数据是会变的。今天你下单的时候鱼香肉丝卖25块等后端运营同学明天把价格改成28块你昨天的订单明细如果还要去关联菜品表查名称和价格查出来的就是改价之后的信息。订单是交易凭证必须保留买家下单那一刻的快照。所以明细表里直接冗余了菜品名称、图片、单价快照这就是典型的“空间换正确性”。1.2 下单前为什么要先把购物车“冻结”下来顺着上面说的快照思路就很好理解下单的核心动作了把购物车里的数据深拷贝成订单明细而不是引用购物车数据。很多第一次写订单功能的人容易犯一个错误——订单明细表直接存shopping_cart表的记录ID或者下单时不拷贝数据只关联ID。这样做的后果是用户下单之后又往购物车里加了个菜或者改了一下购物车某个菜品的数量历史订单的明细就跟着变了。这在真实业务里属于严重事故用户会投诉“我明明只点了两个菜怎么订单里变成三个了”。所以下单的核心流程第一步一定是读取当前用户的购物车列表逐条拷贝成一个新的订单明细对象。拷贝的时候要注意shopping_cart表里有个dish_flavor字段菜品口味比如“微辣”“不要香菜”这个也必须跟着拷贝到订单明细的dish_flavor上否则后厨看到的订单会丢掉口味信息。苍穹外卖的订单明细表里确实有这个字段的设计但很多同学拷贝的时候容易漏掉导致下单成功但口味的维度丢了数据库里存的订单明细跟用户实际选的菜对不上。再往下就是金额计算。遍历拷贝好的明细列表累加每个明细的amount单价乘以数量。这里有一个极其关键的细节金额计算必须用 BigDecimal不能用 double 或 float因为浮点数在计算机里是二进制近似存储的0.1 0.2 都不等于 0.3算钱一定会出精度问题。苍穹外卖的实体类里金额字段都是 BigDecimal就是为了逼着你用正确的方式算钱。2. 用户下单核心链路校验、算钱、落库、清车的顺序不能乱2.1 下单参数到底要校验什么调用用户端下单接口时前端会传过来几个关键参数address_book_id地址簿ID、pay_method支付方式1微信支付2支付宝项目里一般是1、estimated_delivery_time预计送达时间、delivery_time配送时间类型、remark备注、pack_amount包装费等。后端的校验逻辑分两层。第一层是基础非空校验地址ID不能为空、预计送达时间不能为空这些直接用NotNull注解就能搞定。第二层是业务校验这一层容易被忽略购物车不能为空否则用户什么都没点就提交订单生成的订单明细是空的金额是0这种脏数据必须拦下来。地址ID必须存在而且必须是当前登录用户自己的地址。有些人实现的时候只校验了地址ID存在没校验归属换了个ID就能下到别人的地址这是越权漏洞。苍穹外卖里查地址簿的时候一定要带上user_id 当前用户ID作为查询条件。等参数校验过了、地址也查出来了再把地址簿的收件人姓名、手机号、详细地址拷贝到订单主表里对应的字段。为什么订单表里要冗余一份收件人信息跟冗余菜品快照一个道理——地址簿里的数据也会变用户改个地址或者删个地址不能影响已经生成的订单。2.2 订单号生成为什么不用 UUID订单号number字段在苍穹外卖里是有唯一约束的而且前端界面、管理端搜索、骑手端接单都要用这个号码来识别订单所以它必须同时满足几个要求可读、不重复、长度可控。我之前看到有同学图省事直接用UUID.randomUUID().toString()生成的订单号确实是唯一的但32位字符串又长又难记客服跟用户核对订单的时候念起来非常痛苦。外卖场景下的订单号最好是“看一眼能念出来”的格式。苍穹外卖里常用的方案是时间戳加随机数DateTimeFormatter.ofPattern(yyyyMMddHHmmss).format(LocalDateTime.now())拼上四位随机数或者时间戳加用户ID。有人可能会问不加分布式ID生成器吗这个项目是单体架构并发量也远没到需要雪花算法的程度时间戳加随机数已经够用。只要在插入数据库前查一下唯一索引或者依赖数据库唯一约束兜底冲突概率可以忽略。这里有一个实操细节订单号生成之后一定要在插入订单主表前就生成好不要等数据库自增ID返回后再去补订单号。因为订单明细表的外键要关联订单主表的主键ID如果你先插明细再插主表主键ID就不知道往哪填。正确顺序是先创建订单主表对象订单号、金额、状态都填好插入主表拿到自增主键ID再给每个订单明细对象设置order_id 主表ID最后批量插入明细表。2.3 事务边界这一整个操作必须在同一个事务里下单流程从“查购物车”到“清空购物车”中间涉及多次数据库写操作插入订单主表、批量插入订单明细表、删除购物车记录。如果这些操作不在同一个事务里就会出现各种让人抓狂的不一致状态。典型的翻车场景是这样的业务同学代码写到一半插入订单主表成功了批量插入明细表也成功了结果清空购物车的 SQL 因为一个粗心写错了——比如条件漏了user_id导致全表清空。如果每个操作都是自动提交的那购物车被清了订单也生成了看起来好像“成功”了但数据库里别人的购物车也全空了运营后台瞬间涌入一堆投诉。反过来还有一种更隐蔽的坑插入订单明细表的时候某个菜品名称拷贝错了导致 SQL 执行报错但因为之前的操作都已经自动提交了用户看到的反馈是“下单失败”但数据库里却已经插入了一张主表订单金额和信息都是完整的。这种脏数据最麻烦订单状态是待付款但用户根本没收到支付链接管理端还显示有一笔待处理的订单。解决办法就是给下单的 Service 方法加上Transactional注解让整个操作要么全部成功、要么全部回滚。这里有个很多同学踩过的具体细节事务方法内部不要自己 try-catch 吞掉异常。比如你在事务方法里写了 try-catch捕获到异常后吃了一顿 log.error然后正常返回Spring 的事务代理根本感知不到异常发生不会触发回滚。正确做法是让异常抛出去由 Spring 事务管理器统一处理回滚。如果想记录日志可以在 catch 里打日志然后用throw new RuntimeException(e)或自定义业务异常再抛出去。还有一点Transactional默认只对 RuntimeException 回滚对受检异常Exception 及其子类不回滚。如果项目里自定义的业务异常继承的是 Exception那事务也不会回滚。稳妥的做法是显式指定Transactional(rollbackFor Exception.class)别偷懒。2.4 清空购物车为什么要放到事务的最后一步下单事务里最后一步是清空购物车这个顺序不是随便排的它有一个业务上的天然合理性购物车是用户编辑中的草稿订单是已经定型的交易凭证。用户下单成功后草稿自然就没用了。但清空购物车的 SQL 也要注意书写条件苍穹外卖里shopping_cart表是按用户维度存的所以删除条件必须是user_id ?。有些同学习惯用主键ID删但下单接口拿到的只是用户ID没有购物车记录的精确ID列表写DELETE FROM shopping_cart WHERE user_id #{userId}是最直接、最不容易出错的。事务跑完之后接口返回的结果一般就是订单ID、订单号、订单金额这些信息前端拿去做结算页展示或者调起支付。这里可以顺手把下单时间、订单号、待支付金额封装成一个OrderSubmitVO返回给前端省去前端再查一次订单详情的请求。3. 订单状态机从待付款到已完成的流转每一步都得设门槛3.1 状态机设计为什么订单不能随意改状态很多初学者管理端接单功能的时候代码就是一句 UPDATE把订单状态从2改成3。看起来没什么问题但真实业务里订单状态的流转是有顺序、有门槛的不能想改就改强行改状态会导致整个流程错乱。打个比方订单状态就像是机场的安检通道必须一道一道过。你不能说一个乘客还没过安检就直接跑到登机口这在民航系统里是不可能的。订单也一样待付款订单没支付就不能接单待接单订单没接就不能派送派送中的订单没送达就不能已完成。所以苍穹外卖里订单状态流转的每个动作代码里都应该先查当前订单状态做一层前置校验。我把核心的流转关系列出来操作前置状态变更后状态说明支付回调待付款(1) 未支付(0)待接单(2) 已支付(1)用户支付成功后触发商家接单待接单(2)待派送(3)商家确认接单商家拒单待接单(2)已取消(6)要填拒单原因骑手取单/派送待派送(3)派送中(4)骑手开始配送用户确认/自动完成派送中(4)已完成(5)送达后结束实际写代码的时候每个状态流转的方法里都要有一个前置判断。以接单为例先从数据库查出订单判断status是否等于2如果等于6或者5说明订单已经取消或完成了此时再把它改成3就是逻辑错误如果状态等于4、3说明订单已经在派送流程里了再点接单也得拦住。有人可能会问这算不算“过度设计”对于这种真实交易系统一点都不过度。状态错乱轻则导致运营数据不准重则导致骑手白跑一趟、用户白等半小时。所以宁可多写几个 if也不能让状态随便跳。3.2 支付回调的幂等处理重复通知不能重复改状态支付回调是订单模块里幂等性要求最高的一个环节。真实场景里支付平台的通知是会多次推送的可能第一次网络超时了平台隔几分钟又推一次同样的支付成功消息。如果后端每次收到回调都执行一套“改订单状态 加库存 通知商家”的逻辑那订单状态可能被反复修改商家端也会收到多个重复的新订单提醒。苍穹外卖里做的微信支付是模拟环境但处理手法跟真实环境一致。回调接口里先根据订单号查出订单然后判断pay_status是否已经是1已支付。如果已经是1说明之前处理过了直接返回成功不再重复执行后续逻辑如果还是0才把支付状态改成1、订单状态从待付款改成待接单同时记录支付时间。有的人可能会说用数据库唯一索引或者加分布式锁做幂等更靠谱。但在这个项目的体量下一个pay_status判断已经足够了简单直接。真实生产环境再配合数据库操作本身的事务性和状态字段的唯一约束双保险已经很稳。3.3 模拟支付为什么用户端支付后订单没有变成待接单很多同学卡在 day09 的一个问题是用户端点了支付订单状态没变或者页面状态还是待付款。排查这个问题之前先要搞清楚苍穹外卖模拟支付的流程。模拟支付接口的逻辑一般是根据订单ID查询订单校验订单必须存在且属于当前用户然后修改支付状态和订单状态。如果你的模拟支付跑完之后订单状态没变大概率是下面几种情况修改状态的 SQL 条件写错了比如只按订单ID更新但订单ID取的是路径参数还是请求体参数搞混了更新影响的记录数是0。事务提交了但没生效方法上挂了Transactional但方法内部自己 catch 了异常导致状态没改但事务也没报错这个问题前面已经提过。修改状态后缓存没刷新。如果用户端查询订单详情用的数据被 Redis 缓存起来了支付成功后没有清理对应的缓存 key前端查到的还是旧状态。这种问题最隐蔽现象是数据库里状态已经是待接单了页面显示还是待付款。最后一种情况尤其值得注意前面 day08 刚学了 Spring Cache很多同学会把查询用户订单列表的接口加上Cacheable订单状态一变缓存如果不失效状态就“好像没更新”。解决办法是支付成功后调用缓存删除操作或者在修改状态的 Service 方法上加CacheEvict保证查询侧能拿到最新数据。4. 管理端订单管理搜索、接单、派送背后那些你没注意到的条件拼接4.1 订单搜索为什么不能把手机号直接拼进订单表管理端的订单搜索页一般有订单号、手机号、订单状态、下单时间段这几个筛选条件。苍穹外卖用的数据库是 MySQL订单表里没有冗余手机号字段手机号存在用户表user里所以搜索的时候必须把订单表和用户表做关联查询。这就产生了一个 MyBatis 动态 SQL 的经典写法WHERE 条件不是固定的用户可能只填手机号不填订单号也可能只填状态不填时间。如果提前把 WHERE 子句写死了用户没填某个条件时就会执行错误。正确做法是在 Mapper XML 里用where标签搭配if标签每个条件都判断一下传入的参数是否为空。比如status不为空才拼AND o.status #{status}phone不为空才拼AND u.phone #{phone}beginTime和endTime不为空才拼时间范围条件。这样既能复用 SQL又不会因为空参数导致查询结果不对。这里有个管理端搜索特有的细节订单号搜索是精确匹配不是模糊匹配。因为订单号本身就是用户或客服从订单列表里复制的完整号码如果做成 LIKE反而会被 MySQL 前缀匹配规则坑到查不到结果。手机号搜索才是模糊匹配用户可能只记得手机号后四位。4.2 分页查询PageHelper 的正确用法和翻车现场管理端订单列表数据量一大必须分页。苍穹外卖里用的是 PageHelper本身很好用但用错的人也不少。PageHelper 的用法是在查询 SQL 执行之前调用PageHelper.startPage(page, pageSize)然后紧接着写 Mapper 查询方法PageHelper 会自动拦截下一条查询 SQL在语句后面拼上 LIMIT并返回带有分页信息的 PageInfo。最容易翻车的地方是startPage后面跟的第一条 SQL 不是目标查询语句或者一个方法里执行了两条查询PageHelper 会错误地给第二条 SQL 加分页。因为 PageHelper 的原理是 ThreadLocal 存分页参数下一个查询执行完就清除谁先执行谁被分页。所以务必保证startPage和目标查询之间不要穿插其他数据库操作。还有个细节是返回值。用 PageHelper 之后你直接查出来的 List 可以强制转换成Page对象或者用new PageInfo(list)包装PageInfo 里自带total总条数和pages总页数。管理端页面一般还要高亮显示“待接单”数量这个数最好单独写一条COUNT(*)查询不要从分页结果里算。4.3 接单和拒单的状态校验与消息推动接单接口的逻辑很简单订单状态从2改为3。但这里有一个业务上的变体——拒单。拒单不能简单地把状态改成已取消就完了因为用户需要知道商家为什么拒单所以订单表里要有一个rejection_reason拒单原因字段更新状态的同时把原因写进去。苍穹外卖的拒单场景还涉及一个“库存”概念的变体拒单之后支付款要原路退回这里模拟业务一般简化处理。如果用的是模拟支付环境退款逻辑可能不需要真的接入支付平台但至少要在日志里记录退款动作方便后面扩展真实支付对接。接单接口的报文结构也值得注意管理端接单的请求参数就是订单ID但接口里别只 UPDATE 状态最好先 SELECT 一下订单是否存在、状态是否为2。虽然前面提过幂等判断但这里多查一次的意义在于你能拿到订单号、用户手机号这些信息后续要做短信通知、推送商家消息的时候用得上。哪怕当前版本没做通知预留这些数据也能让你后续扩展时少改一遍代码。5. 用户端历史订单与再来一单一个容易被忽略的业务陷阱5.1 历史订单列表分页加状态筛选别把订单明细一次性查出来用户端“我的订单”页面一般是按状态Tab分类的全部、待付款、待接单、待派送、派送中、已完成/已取消所以历史订单查询接口必须支持按status筛选同时还要分页。这里有个数据库查询层面的经验列表页不要一次性把订单明细全部查出来。一个订单如果有10个菜品100条订单就是1000条明细而用户列表页根本不会显示明细只需要显示订单号、金额、状态、下单时间、缩略图封面这几个字段。如果你用嵌套查询把明细也查出来浪费IO不说数据量大之后查询速度会肉眼可见地变慢。正确做法是列表查询只查订单主表点击订单进入详情页时再根据订单ID查明细。这对应苍穹外卖里的两个接口客户端历史订单查询接口分页返回主表字段和订单详情接口返回主表明细表完整信息。5.2 再来一单把历史订单的明细重新塞回购物车注意购物车的去重逻辑“再来一单”这个功能核心逻辑就是把一个已完成订单的明细重新加回购物车。听起来就是查订单明细然后插入 shopping_cart 表但这里有一个业务陷阱购物车里可能已经有这个菜了。比如用户上次点了鱼香肉丝现在购物车里也已经加了一份鱼香肉丝。点“再来一单”的时候如果直接插入购物车就会出现两条一模一样的鱼香肉丝记录。这样用户结算时看到的是两个条目想要合并还得手动删体验很差。正确的处理方式是遍历订单明细时先查一下购物车中是否已存在同一条记录。购物车表里判断“同一条”的标准是dish_id相同且dish_flavor相同如果是套餐就是setmeal_id相同。存在就把数量累加并更新金额不存在才插入新记录。这里还有一个边界情况用户在一个订单里点了两份鱼香肉丝明细表里是一条记录number2购物车里也已经有一份那再来一单的时候应该是累加3份还是累加2份逻辑上应该是2份——因为“再来一单”的语义是把原订单完整复制一遍不是把菜品和购物车现有数量做加法。所以累加逻辑里要读取原订单明细的 number 字段而不是固定加1。最后还要注意插入购物车时create_time要重新取当前时间不能在拷贝的时候把原订单明细的创建时间带过来否则购物车列表的排序会乱前端展示的顺序跟用户实际添加的顺序对不上。6. 我在 day09 实际排错中总结的五个关键经验6.1 常见问题一下单报错但事务没回滚订单还是落库了这个现象前面提到过Service 方法加了Transactional方法内部 try-catch 住了异常数据库里却多了半截数据。排查步骤非常简单在 catch 块里打断点看异常是不是真的被捕获了。看方法是不是被 try-catch 包裹且没有重新抛出。把 catch 里的throw new RuntimeException(e)加上重新测试。我在实际项目里见过更隐蔽的版本方法被 try-catch 了但 catch 里调用了一个远程接口远程接口耗时太久事务一直不提交最后数据库连接池耗尽。所以事务方法里不要做任何外部调用只做纯数据库操作这是铁律。6.2 常见问题二BigDecimal 金额算错100.0 变成 99.99999用 double 算金额的问题很多人觉得“没那么严重”但真出问题的时候特别难看。比如菜品单价 25.0数量3double 算出来可能是 75.00000000000001前端显示带一长串小数用户一看就知道系统有问题。正确做法是金额计算一律使用 BigDecimal除法运算必须指定scale精度和RoundingMode.HALF_UP四舍五入比如price.multiply(new BigDecimal(number)).setScale(2, RoundingMode.HALF_UP)。乘法还好点除法如果不指定精度遇到除不尽的情况会直接抛ArithmeticException项目就会 500。还有个隐藏点数据库 DECIMAL 类型在 MyBatis 映射成 BigDecimal 后做 add、multiply 时记得每次都重新赋值不要直接改原对象——BigDecimal是 immutable 的任何运算都返回新对象不重新赋值就白白算了一遍。6.3 常见问题三支付回调重复执行商家端收到多个新订单提醒幂等判断代码写好了但测试重复回调还是出问题。我排查过一次最后发现是事务隔离级别的问题请求A改了 pay_status1 但还没提交请求B进来了此时 B 读取到的 pay_status 还是 0于是 B 也执行了支付成功的逻辑。这种并发场景在真实业务里很少见因为支付平台通常不会并发推送同一条通知但如果要做稳妥可以在 SQL 里加条件更新UPDATE orders SET pay_status 1, status 2 WHERE id ? AND pay_status 0利用数据库的行锁和受影响行数判断如果影响行数为0说明已经被改过了直接返回。这是“乐观锁”思路比单纯 SELECT 再 UPDATE 更可靠。6.4 常见问题四再来一单时购物车出现重复菜品这个问题的根因就是 5.2 里说的去重逻辑没做。排查的时候可以先手动调接口看插入购物车前有没有查重。查重条件我再强调一次单独按dish_id查是不准的因为同一个菜品可以有不同的口味记录查重必须dish_id dish_flavor双条件套餐则按setmeal_id查。如果购物车表里还存在旧的脏数据比如之前手工测试插入的重复记录先清库再跑业务不要让脏数据干扰排查。6.5 常见问题五管理端订单搜索查不到数据但 SQL 单独执行没问题这个问题的经典原因有两个一是 MyBatis 的if条件类型判断没配对比如phone参数是 String你判断if testphone ! null没问题但如果传的是type这种基础类型 int默认值是0testtype ! null永远为 true导致条件拼错二是查询时间范围时日期格式没对齐前端传的是字符串后端接收的是 LocalDateTime映射器里没加DateTimeFormat解析失败或默认值不对导致时间条件把数据过滤掉了。这类问题排查时最有效的办法是打开 MyBatis 的日志级别把执行的 SQL 打印出来看一眼对比你的预期基本一眼就能看出哪个条件拼错了。结尾做完了 day09 的整个订单模块我对“订单状态机”这几个字算是有了切身体感。以前写接口是“参数进来、数据存进去、返回结果”到订单这里完全不是这么回事——每一笔写操作都牵动着前面购物车的状态、后面管理端的处理流程、再后面骑手的派送链路一环扣一环。我自己最深的体会是能在写代码之前把数据流转的先后顺序和状态流转的每个前置条件想清楚比多写几十行代码有价值得多。如果你也正好卡在这一天的某个报错上不妨先把状态字段的流转图画一遍再去查代码多半能少走不少弯路。