ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用户下单功能如何实现?订单表设计、事务与踩坑全解析

用户下单功能如何实现?订单表设计、事务与踩坑全解析 用户下单这个功能我估计很多人做苍穹外卖做到day08之前心里都觉得挺稳的——菜品浏览、购物车增删改查都跑通了一个下单功能能有多难结果真动手做的时候才发现下单根本不是插一条订单记录那么简单购物车数据要处理、明细要拆分、金额要精度正确、库存和状态要联动一不小心就整出个订单金额对不上、或者明细缺失的幺蛾子。这一篇就把day08用户下单从表设计到核心代码的完整链路掰开讲清楚包括订单号生成、事务边界、购物车清理时机以及我实际联调时踩过的三个坑。适合正在做苍穹外卖项目、或者想把Web项目里下单这类核心交易流程吃透的人参考。1. 下单功能为什么是牵一发动全身的核心节点1.1 到day08为止前面的功能都铺垫了什么苍穹外卖做到day08项目里已经有了一套完整的逛的能力用户看菜品、搜分类、加购物车。这些功能本质上是把用户的意图不断沉淀下来——购物车表里每一行记录都是用户想要购买的凭证。下单功能就是把这份意图真正转化成交易事实的关键一步。它不只是往orders表里插一条数据而是要同时完成三件事把购物车里的菜品数据快照成订单和订单明细清空当前用户的购物车避免重复下单设置订单初始状态为后续的支付、接单、送达等流程铺路所以在动手写代码之前先把下单在整个项目里的位置想清楚比急着写接口重要得多。这也是为什么很多教学项目把下单放在用户端核心功能的最后一环——它前面依赖的东西太多了。1.2 下单涉及到的核心流程边界从业务流程上看用户下单大致是这样一条链路前端把用户选好的菜品和数量通过购物车接口传给后端后端校验用户是否登录、购物车是否有数据查询菜品最新价格重新计算订单总金额生成订单号插入订单主表遍历购物车明细插入订单明细表清空购物车返回订单号前端拿着订单号去走支付流程这里面每一步都有自己的讲究价格为什么不能直接信前端订单号为什么要自己生成而不是用自增ID购物车什么时候清空才不会出问题这些细节我放到后面一节一节说。提示后端对前端传来的金额绝对不能直接信任。一个合格的下单接口一定要用数据库里的菜品价格重新计算一遍总额。2. 订单相关的表结构设计为什么三张表要分开2.1 订单主表要存哪些字段day08用的订单表实际开发中一般叫orders或者order表。因为order是SQL关键字所以很多项目会加后缀苍穹外卖里用的也是类似orders的命名。这张表核心要解决的就是一次下单的整体信息。字段上重点关注这几类订单归属下单用户id、下单时间订单金额总金额、实付金额支付后回填、配送费、打包费订单状态待支付、已支付、已接单、配送中、已完成、已取消关联信息订单号、支付方式、支付时间、配送地址商家信息如果平台是多商家的还要记录店铺id这里最容易忽略的是金额类型。订单金额这种字段用Java的Double或者数据库的float在实际开发里都是大忌——浮点数存在精度误差一分钱都能对不上。正确做法是数据库用decimalJava实体用BigDecimal。苍穹外卖的项目里订单金额这类的字段就是decimal类型这个尽量别动。2.2 订单明细表为什么要单独拆出来用户一次下三个菜订单主表只记录这笔订单总价50元那问题来了后续用户想查我这次买了啥商家想确认这单里有几个菜怎么办所以必须有一张订单明细表每一行记录一个菜品、数量、单价、小计。订单明细表的核心字段订单id关联主表菜品id和菜品名称注意菜品名称要冗余存储菜品图片同样冗余存一份单价、数量、小计金额关于冗余存储这一点你可能会觉得违反范式第一反应是不该把菜品名称存进明细表。但实际开发里菜品是会改名的、下架的、甚至删除的。如果明细表只存菜品id用户查历史订单的时候菜品名称要么查不到要么跟着商家后台的修改一起变了这就是典型的记录被篡改。所以订单快照是必须的——下单那一刻商品长什么样订单里就永远长什么样。2.3 购物车表在下单过程中的角色day08里还有一张表不能忽略就是购物车表。下单前它是待转化的候选数据下单后它的使命就结束了。处理购物车的逻辑上有两种做法先查购物车然后一条条搬进订单明细最后统一清空先清空购物车再插入订单明细我强烈建议用第一种。先清空再插入一旦后续插入失败用户的购物车数据就丢了这种体验非常糟糕。先查出来放到内存里再把购物车数据批量写入明细最后清空购物车这个顺序才安全。3. 下单核心代码实现从Controller到事务收尾3.1 Controller层的接口设计下单接口一般是一个POST请求前端传过来的是一个下单请求对象里面包含地址簿id、预计送达时间、配送费、打包费、备注等这些下单维度信息。真正要关注的是这个接口的设计思路它本身要拿到当前登录用户的id这个id一般从ThreadLocal或者token里解析出来而不是前端传过来。前端传userId的话改个参数就能给别人的账号下单这种安全问题在下单接口里是不可接受的。Controller层代码大概长这样PostMapping(/submit) public ResultOrderSubmitVO submit(RequestBody OrdersSubmitDTO ordersSubmitDTO) { // 从ThreadLocal获取当前登录用户id Long userId BaseContext.getCurrentId(); OrderSubmitVO orderSubmitVO orderService.submitOrder(userId, ordersSubmitDTO); return Result.success(orderSubmitVO); }这里Controller就做三件事拿用户身份、调用Service、包装返回结果。真正的业务逻辑基本都在Service层保持Controller薄薄的这是分层设计的基本功。3.2 Service层业务编排为什么是这个顺序Service层是下单逻辑的主战场。整个下单方法我是按这个顺序编排的Transactional public OrderSubmitVO submitOrder(Long userId, OrdersSubmitDTO ordersSubmitDTO) { // 1. 处理地址簿和收货信息保证地址有效 // 2. 查询购物车数据为空则直接抛异常 // 3. 遍历购物车查询菜品最新价格累加总金额 // 4. 构建订单主表实体插入订单 // 5. 遍历购物车构建订单明细批量插入明细表 // 6. 清空购物车 // 7. 封装返回结果订单号、订单id等 }这个顺序的关键点是第3步遍历购物车的时候一定要用菜品id重新查一次菜品表拿到最新的价格再计算每个明细的小计和整单的总金额。前端传来的总金额只当作参考后端必须自己重新算。这里还有个容易被忽略的细节订单主表插入后订单id是数据库自增生成的。要想让明细表关联这个订单id就必须在插入主表后立刻拿到这个id。MyBatis里可以用useGeneratedKeys把自增主键回填到实体的id字段上再来构建明细数据否则明细表里外键都不知道填什么。3.3 订单号生成的细节订单主键用自增id没问题但对外暴露给用户的订单号尽量不要直接用自增id。原因很简单自增id是连续的用户从订单号就能大概猜出平台的单量而且也不利于后续业务扩展。实际项目中订单号的生成一般有这么几种方案时间戳加随机数、雪花算法、号段模式。在教学项目里比较稳妥又简单的做法是用时间戳加随机数// 订单号年月日时分秒 4位随机数 String orderNumber DateTimeFormatter.ofPattern(yyyyMMddHHmmss) .format(LocalDateTime.now()) RandomUtil.randomNumbers(4);这种方案的够用程度在单体阶段没问题。如果你对这块感兴趣可以再去了解一下雪花算法它解决的是分布式场景下订单号全局唯一的问题。但以day08的复杂度来讲时间戳加随机数已经完全够用了。3.4 事务注解放在类上还是方法上我在代码上面标了Transactional这是下单方法绝对离不开的一个注解。下单涉及多张表的写操作插入订单主表、批量插入明细表、删除购物车。任何一个环节失败前面插入的数据都会变成脏数据。我之前见过一个新手写法只在Service实现类上标注Transactional或者干脆每个方法都标结果该回滚的时候没回滚问题排查起来特别费劲。Spring里事务的默认回滚规则是RuntimeException异常回滚受检异常默认不回滚。所以我在写Service方法时遇到业务校验失败一般直接抛一个运行时异常配合事务注解就能保证数据一致性。受检异常的话记得在注解里加上rollbackFor Exception.class或者把异常包装成运行时异常抛出。4. 下单过程中的几个关键决策和踩坑记录4.1 购物车为空的情况如何优雅处理下单第一步得查购物车但如果用户购物车是空的那说明前端传来的数据本身就有问题——要么是用户没加菜就点了下单要么是之前已经下单了但是前端没有刷新页面购物车显示的还是旧数据。这种情况直接往下走最后插入的明细是空的订单金额是0生成的订单完全没意义。我在实测中发现很多同学在这里的处理方式是顺手插一条空订单等前端把空订单暴露给用户了才发现问题。正确做法是购物车为空直接抛出业务异常提示购物车为空无法下单让前端引导用户先去点餐。ListShoppingCart cartList shoppingCartMapper.list(userId); if (cartList null || cartList.isEmpty()) { throw new BusinessException(购物车为空无法下单); }这个判断位置要放在所有操作的最前面越早拦截后面浪费的数据库操作越少。4.2 金额计算必须用BigDecimal订单里有总额、明细小计还有可能会加配送费、打包费。这些金额计算如果写成Double加法开发期可能看不出来一旦遇到0.1 0.2这种场景浮点误差直接就埋进去了。我在写完金额累加逻辑之后专门做过一次测试三份菜品价格加起来用Double累加得到的结果在特定场景下会差那么零点零零几。虽然展示给用户的时候可能被四舍五入掉了但入数据库、对账、后续退款计算每一步都会把这个误差放大。用BigDecimal虽然代码上多写几行但踏实多了BigDecimal amount new BigDecimal(0.00); for (ShoppingCart item : cartList) { // 查最新菜品价格 BigDecimal itemAmount price.multiply(new BigDecimal(item.getNumber())); amount amount.add(itemAmount); }注意BigDecimal的构造方式尽量用字符串构造避免直接用double构造带来的精度问题。4.3 清空购物车的时机为什么要放到最后我在前面说这个顺序很重要展开讲讲当年我碰到的一个真实场景第一次我写的下单逻辑是先把购物车清空然后循环往订单明细插数据。有一天测试环境突然挂了原因是一条明细插入失败了抛了异常结果购物车已经先被清空了。用户的购物车一夜之间被下单了但订单实际没生成成功。用户再进到小程序里一看购物车空了订单列表里什么都没有这体验就是灾难。所以这个顺序我要再强调一遍查询购物车 - 插入订单主表 - 插入明细表 - 全部成功后再清空购物车。配合事务哪怕清空购物车这一步最后失败了整单回滚用户下次还能重新下单。4.4 联调时前端传参不一致的坑下单接口联调时前端传参字段名和后端实体属性名对不上是出现频率最高的问题。我遇到过前端传的是estimatedDeliveryTime后端字段叫deliveryTime结果下单成功了预计送达时间却是null用户看不到什么时候能送到。这种问题的排查思路很简单但也容易被忽略先在后端接口打个断点看接收到的DTO里哪些字段是null再去前端network面板看实际的请求参数名。不要一上来就怀疑数据库、怀疑事务先看数据有没有传到位八成的问题都会在这里解决。一个小经验前端传的时间一般是字符串后端如果直接用LocalDateTime接收格式对不上也会报错。所以在DTO里用String接收时间然后在Service里手动解析这样前端传什么的兼容性都更好。4.5 状态字段用Integer还是枚举订单状态字段我在day08里用的是Integer配合一个状态常量类或者枚举来做语义化。这个过程到后面商家端接单用户催单等功能会反复用到所以一开始就把状态定义清楚会省很多事情。比如我习惯这么定义1待付款2已付款/待接单3已接单/配送中4已完成5已取消定义好之后后续判断订单有没有付款、能不能接单直接比较这个状态值就行。用枚举写会让代码更优雅但教学项目里很多地方直接用Integer这个不强求。关键是别把状态值硬编码散落在业务代码里尽量集中管理。5. 下单接口自测从Postman到小程序联调5.1 自测的步骤和要点写完接口别急着提交联调先在Postman里把自测场景走通。我一般按这个顺序跑带着token请求下单接口看返回的订单号是否正常生成查看数据库orders表确认订单主表记录生成查看order_detail表确认明细条数和购物车一致查看购物车表确认已经清空再调用一次下单接口验证购物车为空时会不会正确拦截这里第5步特别重要很多同学在购物车为空的情况下测试为了方便测试又往购物车塞了数据导致购物车为空这条分支永远测不到。这个分支恰恰是用户最容易触发的场景。5.2 事务回滚怎么验证事务是否生效很多人只在代码里加了Transactional就认为万事大吉。我的建议是主动测一次回滚在插入明细表的代码后面临时加一行int i 1 / 0;制造一个运行时异常然后正常触发下单。如果事务生效下单完成后订单主表和明细表都应该没有数据。如果不生效订单主表会残留一条记录而明细没有。等验证完把测试代码删掉再跑一遍正常流程这条经验比背十遍事务原理都实在。5.3 金额边界情况下单金额的边界情况有两个值得测大量菜品下订单额会不会溢出、0金额能不能下单。菜品数量正常情况下不会太夸张但测试的时候可以构造一些极端数据看看接口的健壮性。0金额订单也就是用户把所有菜品的价格都改成0之后去下单这种情况在后台配置错误的时候可能出现。我的处理方式是通过校验金额必须大于0否则直接拒绝下单。6. day08之后这块代码还能怎么演进6.1 从单体到分布式的订单隔离day08的订单逻辑都写在一个Service方法里数据库操作也都在一个事务里单体部署下这样最简单、最可靠。当项目往后走会面临两个变化订单量大了要拆库或者要接独立的订单中心。那时候查询购物车 - 插入订单 - 清空购物车这套本地事务逻辑就要开始考虑分布式事务方案了。但那是后话day08的阶段先把本地事务吃透后续才有基础理解更复杂的方案。6.2 支付回调里更新订单状态做完下单下一步急迫的功能就是支付。支付回调的逻辑尤其要注意支付回调进来后不能直接根据支付成功就把所有订单状态更新为已完成。正确流程是待付款 - 已付款然后商家接单配送完成后才到已完成。每两个状态之间都要校验当前状态是否符合预期。用户在待付款状态收到支付回调状态能正确流转如果一单已取消的订单突然收到支付成功回调那就绝对不能更新状态反而要触发退款流程。这块我在后面做支付功能的时候还会细讲day08能做到把下单和支付之间的状态衔接想清楚就算有进步了。6.3 自己动手加一个小功能订单列表查询学完day08之后最推荐的练习不是继续往下赶进度而是停下来自己写一个我的订单列表接口。这个功能用到的东西你全会联表查询、状态筛选、分页、时间倒序。但写出来之后你会更深刻地理解订单主表、明细表拆分的价值——查询的时候也一样的顺手。7. 一点实际体会做用户下单这块最深的感悟就是功能看起来只有几行代码但每一行背后都是对业务边界的思考。购物车为空要拦、金额要重算、状态要流转、事务要保住这些额外工作决定了这个功能是demo还是真的能上线的东西。你在day08花的时间后面会在支付、退款、订单统计这些环节几百倍地赚回来。遇到报错别急着改代码先把购物车、订单、明细这几张表的数据拉出来看一眼基本上80%的问题答案都在数据里。
返回列表