尧图精选

苍穹外卖订单模块实战:表设计、下单事务与状态机避坑指南

🕒 发布时间:2026/10/1 3:33:39 📁 来源:尧图网络
做苍穹外卖这种项目最容易产生幻觉的是前六天登录、分类、菜品、套餐、购物车全是增删改查看起来和气生财。等到第七天订单模块一上来突然所有东西都开始互相咬合——下单要同时动购物车、地址簿、订单主表、订单明细表还要保证任何一个环节出问题都不能留下脏数据。这篇文章就记录我在苍穹外卖项目第7天实现用户交易链路的过程包含订单表设计、下单接口的完整实现、状态机的坑、历史订单查询的细节以及这次踩过的几个比较典型的错误。如果你也在这个项目里推进或者正在做类似的外卖、电商交易模块这篇可以作为参考。1. day7前面的家底为什么订单模块必须放在第7天1.1 day1到day6到底搭了哪些东西很多刚开始做苍穹外卖的人会有一个错觉前面几天好像一直在做无关紧要的后台管理无非是员工登录、分类管理、菜品管理、套餐管理这一套CRUD跟真正的外卖两个字没太大关系。等到第7天订单模块一铺开你才会意识到前面那些天攒下来的每一块积木都是订单流程里绕不开的依赖。day1到day6我这边大致是这样的节奏环境搭建Spring Boot MyBatis MySQL Redis nginx静态资源、员工登录JWT生成与校验、员工管理、分类管理、菜品管理含文件上传到OSS、套餐管理以及用户端的微信登录和商品浏览。这里面有几个东西是订单模块真正的前置条件用户身份用户端必须通过微信登录拿到userId后续所有订单操作都挂在userId下面。商品数据菜品表、套餐表提供了下单时需要读取的价格、名称、图片等快照数据。购物车用户加购的数据都存在购物车表里下单本质上就是把购物车里的东西转换成订单。地址簿用户选择收货地址地址信息要冗余进订单表避免以后地址被改动影响历史订单。如果你把订单模块硬塞到day3之前做你会发现根本做不下去——连当前用户是谁都拿不到更不用说商品价格从哪来。所以项目把订单放在第7天是有道理的前六天解决数据从哪来第七天解决数据怎么串起来。1.2 订单模块在整个项目里的数据流向订单模块是苍穹外卖里第一个真正意义上的多表联动模块。用一句话概括它的核心数据流就是用户浏览商品 - 加购物车 - 提交订单 - 后端读取购物车生成订单主表和明细表 - 清空购物车 - 订单进入状态机流转 - 用户随时查历史订单。这里的每一步都不是孤立的。提交订单接口要同时操作四张表地址簿表校验收货地址是否存在、购物车表取出用户勾选的商品、订单表插入一条主记录、订单明细表把购物车里的每一条商品转成明细行。如果其中任何一步失败前面成功插入的数据都要回滚否则就会出现订单主表有记录但没有明细或者购物车被清空了但订单没生成这种脏数据。所以我在day7开始之前给自己列了一个任务清单设计订单表结构、实现下单接口、设计订单号生成规则、搞定历史订单分页查询、处理取消订单和再来一单。这些任务全部完成之后用户端的交易闭环才算真正跑通。这篇文章也就按照这个清单往下讲。2. 订单表结构设计小票上和厨房里要留的东西不一样2.1 orders主表和order_detail明细表的字段拆解先看订单主表。苍穹外卖的orders表字段设计得比较全我根据自己的实现整理了一份字段可以分为几类订单标识、用户信息、收货信息、金额信息、状态信息、时间信息。字段名类型说明idbigint订单主键自增numbervarchar(50)订单号对外展示用唯一user_idbigint下单用户idaddress_book_idbigint地址簿id下单那一刻的地址引用order_timedatetime下单时间checkout_timedatetime支付时间未支付时为空pay_methodint支付方式1微信 2支付宝pay_statustinyint支付状态0未支付 1已支付 2退款amountdecimal(10,2)订单总金额精确到分remarkvarchar(100)用户备注phonevarchar(11)收货人手机号addressvarchar(255)收货地址consigneevarchar(50)收货人姓名user_namevarchar(50)下单用户用户名冗余字段statusint订单状态1待付款 2待接单 3已接单 4派送中 5已完成 6已取消cancel_reasonvarchar(255)取消原因rejection_reasonvarchar(255)商家拒单原因cancel_timedatetime取消时间estimate_delivery_timedatetime预计送达时间delivery_timedatetime实际送达时间pack_amountdecimal(10,2)打包费tableware_numberint餐具数量tableware_statusint餐具状态0按数量提供 1提供一次性餐具再看order_detail订单明细表。它跟主表的关系是一对多一个订单对应多条明细。核心字段包括id、order_id、dish_id菜品id、setmeal_id套餐id、dish_flavor口味、number数量、amount金额小计、image图片快照、name名称快照。这里有个关键点很多人第一次接触时不太理解为什么订单里要冗余 address、phone、consignee、user_name、dish名称、图片这些字段明明可以用外键关联地址簿、商品表去查询。答案是历史订单展示的是下单那一刻的事实不是现在的数据。用户的地址可能改了、菜品可能改了价格、套餐可能下架了如果订单查询时刻去联表查最新数据用户看到的就会是昨天买的时候显示32元今天历史订单里变成了38元完全没法解释。这就跟去餐厅结账拿到的小票一样小票上打印的菜名、单价、数量是那一刻的交易快照不会因为餐厅第二天调整菜单而变化。订单表冗余这些字段本质上就是在数据库里给用户打了一张电子小票。2.2 金额字段为什么必须用decimal订单模块里最不能妥协的一个设计就是金额字段的类型。orders表和order_detail表里的amount、pack_amount一律用decimal(10,2)Java代码里对应BigDecimal禁止用float或者double。原因不用我多说写过几年代码的都见过这个经典场景double a 0.1; double b 0.2; System.out.println(a b); // 0.30000000000000004金额出现这种尾差轻则页面显示怪异重则对账不平甚至会因为数据库字段是float导致精度丢失。MySQL里float和double是近似值存储decimal才是精确值。这一点在订单模块里不是建议用decimal而是必须用decimal。另外提一句下单接口里前端会传一个amount上来但后端绝不能直接信任这个值。正确做法是后端重新读取购物车里的菜品、套餐用数据库里的价格自己算一遍总金额前端传的amount最多用来做一致性校验或者干脆忽略。道理很简单前端传来的数据是用户可以篡改的订单金额如果以用户传入为准那就等于把定价权交给了用户。3. 下单接口完整敲一遍以及每个if和事务存在的理由3.1 DTO和VO先定义清楚下单接口我习惯先把入参和出参定义好后面业务逻辑才不会写乱。用户端提交订单入参是OrdersSubmitDTO包含这些信息addressBookId地址簿id、payMethod支付方式、remark备注、estimatedDeliveryTime预计送达时间、deliveryStatus配送状态、tablewareNumber餐具数量、tablewareStatus餐具状态。有些同学会把amount也放进DTO里我的建议是放可以但后端逻辑里不要拿它当最终金额。真正下单成功后返回给前端的OrderSubmitVO包含id订单id、orderNumber订单号、orderAmount订单金额、orderTime下单时间。为什么要单独搞VO而不是直接返回实体因为订单表里的字段太多了直接序列化给前端本不该暴露的字段比如支付状态、用户id也会一起出去既浪费流量又有风险。用VO做一次裁剪干净利落。3.2 核心方法从购物车到订单的完整转换直接贴我当时实现的核心Service方法注释写得很详细可以先整体看一遍再往下读解释Override Transactional public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) { // 1. 校验地址簿 AddressBook addressBook addressBookMapper.getById(ordersSubmitDTO.getAddressBookId()); if (addressBook null) { throw new BusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL); } // 2. 查询当前用户的购物车列表 Long userId BaseContext.getCurrentId(); ShoppingCart shoppingCart new ShoppingCart(); shoppingCart.setUserId(userId); ListShoppingCart shoppingCartList shoppingCartMapper.list(shoppingCart); if (shoppingCartList null || shoppingCartList.isEmpty()) { throw new BusinessException(MessageConstant.SHOPPING_CART_IS_NULL); } // 3. 构造订单主表数据 Orders orders new Orders(); orders.setNumber(String.valueOf(System.currentTimeMillis())); orders.setStatus(Orders.PENDING_PAYMENT); orders.setUserId(userId); orders.setAddressBookId(addressBook.getId()); orders.setOrderTime(LocalDateTime.now()); orders.setPayMethod(ordersSubmitDTO.getPayMethod()); orders.setRemark(ordersSubmitDTO.getRemark()); orders.setPhone(addressBook.getPhone()); orders.setAddress(addressBook.getDetail()); orders.setConsignee(addressBook.getConsignee()); orders.setEstimatedDeliveryTime(ordersSubmitDTO.getEstimatedDeliveryTime()); orders.setDeliveryStatus(ordersSubmitDTO.getDeliveryStatus()); orders.setPackAmount(ordersSubmitDTO.getPackAmount()); orders.setTablewareNumber(ordersSubmitDTO.getTablewareNumber()); orders.setTablewareStatus(ordersSubmitDTO.getTablewareStatus()); // 4. 遍历购物车计算总金额并生成订单明细 BigDecimal amount BigDecimal.ZERO; ListOrderDetail orderDetailList new ArrayList(); for (ShoppingCart cart : shoppingCartList) { OrderDetail orderDetail new OrderDetail(); orderDetail.setName(cart.getName()); orderDetail.setImage(cart.getImage()); orderDetail.setDishId(cart.getDishId()); orderDetail.setSetmealId(cart.getSetmealId()); orderDetail.setDishFlavor(cart.getDishFlavor()); orderDetail.setNumber(cart.getNumber()); orderDetail.setAmount(cart.getAmount()); orderDetailList.add(orderDetail); // 金额累加单价 * 数量 amount amount.add(cart.getAmount().multiply(BigDecimal.valueOf(cart.getNumber()))); } orders.setAmount(amount); // 5. 插入订单主表利用useGeneratedKeys拿到订单id orderMapper.insert(orders); Long orderId orders.getId(); // 6. 批量插入订单明细 for (OrderDetail orderDetail : orderDetailList) { orderDetail.setOrderId(orderId); orderDetailMapper.insert(orderDetail); } // 7. 清空当前用户的购物车 shoppingCartMapper.clean(userId); // 8. 组装返回VO OrderSubmitVO orderSubmitVO OrderSubmitVO.builder() .id(orderId) .orderNumber(orders.getNumber()) .orderAmount(orders.getAmount()) .orderTime(orders.getOrderTime()) .build(); return orderSubmitVO; }有人会问为什么第3步用System.currentTimeMillis()当订单号后面我还要单独讲订单号规则这里先不展开记住了——上线项目里订单号生成是个独立话题。3.3 这段代码里的几个为什么写这段代码时最值得琢磨的不是怎么把数据插进去而是为什么要这么插。第一个问题是为什么要先插入订单主表再插入明细因为明细表里的order_id依赖主表的自增主键必须先insert主表拿回id明细才能关联上去。所以订单表的insert语句里必须配置useGeneratedKeystrue keyPropertyid否则你插入之后orders.getId()拿到的永远是null明细全部变成孤儿数据。第二个问题是购物车清空为什么放在最后一步这看起来是顺序问题实际上是个数据安全问题。假如你先把购物车清了再去插入订单明细中途某个菜品已经被商家下架导致查询失败整个事务回滚后购物车的数据已经丢了回滚会恢复这点事务可以保证但更危险的是如果清空逻辑不在同一个事务里或者事务因异常没触发回滚用户购物车会直接空掉。把清空购物车放在所有写操作的最后配合Transactional即使前面失败购物车数据也能完整回滚用户重新提交一次就行不会丢失。第三个问题是金额为什么在内存里先算好然后一次性设置进orders因为订单主表和明细表都要金额主表是汇总值明细表是单项值。如果在插入主表之后再update金额就要多一次数据库写操作完全没必要。先在代码里遍历购物车把钱算清楚再组装对象一步到位。第四个问题是为什么要用Transactional这个接口一次请求内至少有三次写操作插入订单、插入N条明细、清空购物车。任何一次失败都必须保证前面已写入的数据全部撤销。Spring的声明式事务在这里是必须的不是可选项。4. 订单状态机、订单号生成、支付状态这些字段不是随便定的4.1 订单状态流转一张图看懂整个履约过程苍穹外卖的订单状态用int表示一共6种1待付款、2待接单、3已接单、4派送中、5已完成、6已取消。从用户下单到订单完结正常路径是这样走的用户提交订单后订单状态是1待付款。用户支付成功支付回调里把pay_status改为1同时订单状态从1变成2待接单。商家端看到新订单后接单状态从2变成3已接单。商家出餐后点击派送状态变成4派送中。骑手送达后用户点击确认收货或者系统超时自动确认状态变成5已完成。特殊路径也要说出来用户在待付款阶段可以主动取消订单或者超时未支付被系统取消状态变成6同时需要记录cancel_reason和cancel_time。商家在待接单阶段可以选择拒单状态也变成6但拒单必须有rejection_reason方便用户申诉。已接单之后原则上不能再取消如果真有极端情况需要取消要走售后流程而不是改状态字段。理解这套状态机对后面做管理端订单查询非常关键。因为管理端的新订单已接单派送中等标签本质上就是按照status字段做筛选每个标签对应一组状态值。如果你在设计阶段状态值混乱后面每个查询条件都要写一堆魔法数字维护起来非常痛苦。4.2 为什么状态字段用int而不是字符串我见过一些项目用varchar存订单状态比如PENDING_PAYMENTCOMPLETED理由是可读性好。但在苍穹外卖这种体量的项目里我强烈建议用int配合常量类维护。原因有三个一是存储空间和查询效率int固定4字节字符串还要额外存储长度信息订单表数据量大之后索引性能会有差距二是写代码时用Orders.PENDING_PAYMENT这种常量可读性并不比字符串差三是数据库层面做统计时group by status用int更利落不用case when转义一堆字符串。我在项目里是这样定义常量的public class Orders { public static final Integer PENDING_PAYMENT 1; public static final Integer PENDING_ACCEPT 2; public static final Integer ACCEPTED 3; public static final Integer DELIVERY_IN_PROGRESS 4; public static final Integer COMPLETED 5; public static final Integer CANCELLED 6; }业务代码里永远写Orders.ACCEPTED不写裸数字3这样一个月后再回来看代码还是能秒懂。4.3 订单号生成看似随手一写实际上有讲究订单号是给用户看、给客服查、给支付平台做商户订单号使用的所以它必须满足三个要求唯一、可读、不易被猜到。唯一性不用解释。可读性指的是客服或者用户看到订单号能大概判断出下单时间——所以订单号里带上时间前缀很有价值。不易被猜到是因为如果订单号是连续自增id用户可以通过订单号差值估算出平台的真实订单量这是数据泄露而且也很容易被恶意遍历。我当时用的规则是当前时间戳13位毫秒 6位随机数拼成字符串后转long存入number字段。更规范一点的做法是yyyyMMddHHmmss 用户id后四位 随机数既能看出时间又能看出是哪个用户下的单随机数防止并发重复。至于雪花算法不是不能用但对于目前这个体量有点重量级订单量没到分布式分表的量级之前时间加随机数足够扛住并发真到了扛不住的时候再换也不迟。顺带说一下pay_status和status的区别这两个字段经常被混为一谈。pay_status只管钱的状态0未付款、1已付款、2退款status管的是订单履约到哪一步了。一个订单完全可能处于5已完成但用户申请退款走了售后pay_status变成2的状态。语义分开后面接支付回调、做退款流程才不会互相踩踏。5. 历史订单与再来一单CRUD里藏着的几个真实业务细节5.1 历史订单分页查询最容易出现N1的地方用户端我的订单是高频接口需求是分页返回订单列表每个订单要包含订单下的菜品明细列表。有同学会这样实现先分页查订单主表拿到每条订单循环遍历再查一次明细表。// 反面示例 ListOrder orders orderMapper.page(query); for (Order order : orders) { ListOrderDetail details orderDetailMapper.getByOrderId(order.getId()); // 组装... }如果一页10条订单就产生10次明细查询加上主查询一共11次数据库交互。订单表数据量一大这个接口就会肉眼可见地变慢。这就是典型的N1问题。我当时改成两步走第一步分页查订单主表拿到当前页的订单id列表第二步一次性查该id列表下的所有明细比如select * from order_detail where order_id in (1,2,3,...)然后在内存里按照order_id分组再拼装到对应订单上。数据库只交互两次数据量上去后性能差距非常明显。5.2 取消订单一个update语句也要写条件用户取消订单表面上是把status改成6但实际上有几个点要注意。第一要先校验订单归属确保这个订单是当前登录用户下的不能A用户把B用户的订单取消了。第二要判断当前状态是否允许取消——待付款可以直接取消待接单状态要看业务规则有些商家允许用户取消有些要联系客服。我这边实现的规则是只有待付款状态才允许用户主动取消。第三更新语句要带状态条件这是很多人忽略的并发安全点update orders set status 6, cancel_reason ?, cancel_time now() where id ? and status 1这条SQL的意思是只有订单当前还是待付款状态这次取消才能生效。假设用户同时开着两个页面一边付款成功一边点了取消如果update不带where status条件取消操作会直接把已付款的订单改成已取消而钱已经付了这就成了重大事故。带条件更新后如果受影响行数为0说明订单状态已经不是待付款这时候要重新查一次给用户友好提示订单已支付不能取消。5.3 再来一单把订单明细重新放回购物车再来一单这个功能第一眼看是个锦上添花的小需求但实现起来比想象中多一些细节。它不是把旧订单再插入一遍而是把原订单的明细数据重新映射成购物车记录。我踩过的坑是菜品和套餐的分类问题。订单明细表里有dish_id和setmeal_id两个字段一个订单明细只会填其中一个另一个是null。做映射时必须分开判断for (OrderDetail detail : orderDetailList) { ShoppingCart cart new ShoppingCart(); cart.setName(detail.getName()); cart.setImage(detail.getImage()); cart.setNumber(detail.getNumber()); cart.setDishFlavor(detail.getDishFlavor()); cart.setAmount(detail.getAmount()); if (detail.getDishId() ! null) { cart.setDishId(detail.getDishId()); } else { cart.setSetmealId(detail.getSetmealId()); } cart.setUserId(userId); shoppingCartMapper.insert(cart); }还有两个隐藏细节一是dishFlavor口味字段必须原样带过去否则用户上次点的少辣这次就丢了二是image图片字段也要带有些购物车组件是靠image字段渲染图片的字段为空会出现破图。至于这个菜品现在是否已经下架我当时没有在再来一单里做强校验直接插进购物车等到用户重新提交订单时由下单接口再校验一遍这样实现最简单也不会出现点击再来一单直接报错的糟糕体验。6. 实测踩坑记录事务失效、金额精度、状态并发6.1 事务失效同一个类里调用注解形同虚设我第一次写下单接口时把生成订单和清空购物车的逻辑都放在Service内部然后在一个方法里用this.xxx()调用另一个带Transactional的方法结果测试时故意在插入明细的SQL里写错字段名发现订单主表的数据居然没有被回滚。排查了半天才反应过来Spring的声明式事务是基于AOP动态代理实现的只有外部调用才会走代理逻辑。同类内部调用走的是this对象代理不生效Transactional自然失效。解决办法有三种一是把事务代码拆到另一个Service类里让Spring来代理二是自己注入ApplicationContext通过上下文拿代理对象再调用三是用TransactionTemplate手动管理事务。我在项目里选择了最直接的那种——把下单相关的写操作统一放在一个方法里直接用Transactional不搞类内部互相调用从根本上避开这个问题。6.2 金额计算一步double整个项目跟着遭殃订单模块的金额计算链条很长购物车里有单价下单时要算总价历史订单要显示小计管理端后期还要做营业额统计。这里任何一环用了double或float后面所有环节都会跟着吃精度亏。我的教训来自下单方法里的一个写法。购物车实体里的amount字段是BigDecimal但我在累加总金额时图省事先用cart.getAmount().doubleValue()取出来乘完再包回BigDecimal结果总金额偶尔出现像33.550000000000004这种数。虽然页面展示时格式化一下看不出问题但存进数据库就糟了decimal列收到一个超长小数会被截断对账时怎么都对不上。正确写法只有一种全程BigDecimal运算金额累加就像前面代码示例里那样amount amount.add(cart.getAmount().multiply(BigDecimal.valueOf(cart.getNumber())));另外还要注意new BigDecimal(0.1)和BigDecimal.valueOf(0.1)的区别。前者本质是把double的二进制近似值原样转进来照样会有尾差后者内部用的是String构造才是我们想要的结果。项目里统一用BigDecimal.valueOf不要new。6.3 状态并发更新条件更新挡住双操作同时发生商家接单这个动作看起来就是一个update语句但我刚开始写的时候没有加状态条件-- 错误示例 update orders set status 3 where id ?后来测试发现一个真实场景管理端在接单的同时用户在App端点了取消。两个请求几乎同时到达取消请求先执行把订单从2改成了6接单请求随后执行直接把6改成了3。用户那边显示已取消商家这边显示已接单两边数据就打架了。改成条件更新后这个bug从根上解决update orders set status 3 where id ? and status 2如果更新行数为0说明订单已经不是待接单状态代码里再查询一次给商家提示该订单已被取消无法接单。这种思路不光适用于接单操作派送、完成、拒单、取消所有状态流转都应该带上前置状态条件。6.4 顺带提两个影响体验的小细节LocalDateTime序列化问题。Spring Boot默认把LocalDateTime序列化成2025-01-08T12:30:00这种带T的ISO格式前端直接展示会很突兀。我是在application.yml里配了全局Jackson格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8下单时间、支付时间这类字段前端就能显示成2025-01-08 12:30:00的正常格式。购物车清空条件的细节。清空购物车SQL是delete from shopping_cart where user_id ?这个在单一店铺场景下没问题。但如果以后项目扩展成多店铺一个用户在不同店铺各有一份购物车下单后只应该清空当前店铺的购物车SQL就要加上shop_id条件。苍穹外卖目前是单店铺模式但写代码时留个心眼把清空条件设计成可扩展的参数不算白费功夫。做完day7这一天的内容我最大的体会是外卖这类项目的复杂度从来不在单个接口的写法而在状态如何流转、数据如何保持一致。订单模块把前面所有看起来简单的模块都真正串了起来等到第8天接支付回调、第9天做管理端订单管理的时候你会发现今天打的地基稳不稳直接决定后面顺不顺。个人建议是在做完下单功能后自己手动去数据库里删一条正在跑的数据或者故意让一条明细插入失败看看会不会报错、会不会留下脏数据这个动作比多看十遍代码都有用。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →