从下单这个功能开始,苍穹外卖项目算是真正进入了核心业务区。day08这一节看似只是一个“用户下单”的接口,但牵扯到订单表结构设计、购物车数据读取、地址薄校验、菜品口味快照、库存扣减、事务回滚、甚至高并发下的重复下单问题,每一步都有坑。这篇文章我根据自己做苍穹外卖的完整过程,把用户下单这个模块的拆解思路、代码实现、踩坑记录都梳理出来,希望能给正在做到这个环节的同学一点参考。
1. 内容整体设计与思路拆解
1.1 下单功能在整个苍穹外卖项目中的定位
苍穹外卖是一个典型的移动端外卖C端项目,核心链路无非是“用户浏览菜品 → 加入购物车 → 确认订单 → 提交订单 → 支付 → 商家接单 → 配送给用户”。day08要完成的“用户下单”,是整个交易闭环中最关键的一环。前面七天都在做员工端、分类、菜品、套餐的管理,本质上是给商家用的后台功能,数据操作多是单表CRUD,业务逻辑相对直白。到了用户下单这里,才开始真正把多个业务流程串起来:用户要先有收货地址,购物车里要有菜品,下单时要校验菜品是否还在售卖、库存是否充足,订单生成后还要清空购物车,并且在极端情况下要防止同一用户疯狂点击导致生成重复订单。
从课程进度安排来看,day08的内容安排是合理的。它没有一上来就直接怼支付,而是先让用户把订单提交逻辑走通,生成一条待支付订单,这样后续接支付、接订单状态流转时,才有一个稳固的数据支撑。
1.2 为什么下单流程需要这样的顺序设计
很多同学在做这个功能时,容易把思路局限在“插入一条订单记录”上,但实际上一个完整的下单接口,在苍穹外卖项目里至少要跑通这么几步:
- 接收前端传来的参数:地址簿id、购物车数据(或者直接从购物车表查)、备注、预计送达时间等;
- 查询用户当前购物车列表,校验购物车是否为空;
- 遍历购物车中的每个菜品/套餐,查询最新的菜品状态、价格,重新计算订单金额,而不是直接信任前端传过来的总价;
- 构造Order实体,插入订单主表,获取订单id;
- 遍历购物车明细,构造OrderDetail列表,批量插入订单明细表;
- 清空当前用户的购物车;
- 返回订单id给前端,用于后续支付。
为什么要这样设计?核心原因只有一个:不能信任前端数据。外卖平台的菜品价格、库存是实时变化的,用户在购物车停留的几分钟内,菜品可能已经下架或者涨价了。如果直接把前端传过来的总价写入订单,相当于把钱袋子交给用户来填,这在真实业务里是不可接受的。所以后端必须要主动重新查一遍购物车数据、重新计算金额,这是做交易类功能的基本素养。
另外,先插入订单主表获取自增id,再插入明细表,是因为明细表需要外键关联订单主表的id。虽然也可以用业务订单号来关联,但用自增主键更简单直接,也是大多数项目采用的方式。
1.3 基于常见实践的方案选型补充
如果你是在跟着苍穹外卖的课程敲代码,会发现它用的是Spring Boot + MyBatis + MySQL + Redis这套经典组合。下单这部分没怎么用Redis,但是在优化重复下单问题时,可以用Redis做分布式锁或者幂等控制,我在后文会专门讲。事务管理用的是Spring的@Transactional注解,简单够用,但对于交易核心链路,我建议你理解一下事务的传播行为和回滚机制,避免出现“订单主表插入了,明细表插入失败,数据不一致”这种尴尬情况。
2. 核心细节解析与实操要点
2.1 订单表与订单明细表的设计思路
先看两张表的字段设计,这是理解下单代码的前提。苍穹外卖的订单表orders,主要字段有:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| number | varchar | 订单号 |
| status | int | 订单状态:1待付款 2待接单 3已接单 4派送中 5已完成 6已取消 |
| user_id | bigint | 下单用户id |
| address_book_id | bigint | 地址簿id |
| order_time | datetime | 下单时间 |
| checkout_time | datetime | 结账时间 |
| pay_method | int | 支付方式:1微信支付 2支付宝 |
| amount | decimal | 实收金额 |
| remark | varchar | 备注 |
| phone | varchar | 联系电话 |
| address | varchar | 收货地址 |
| user_name | varchar | 用户名 |
| consignee | varchar | 收货人 |
| cancel_reason | varchar | 取消原因 |
| delivery_status | int | 配送状态 |
| estimated_delivery_time | datetime | 预计送达时间 |
| pack_amount | decimal | 打包费 |
| tableware_number | int | 餐具数量 |
| tableware_status | int | 餐具状态 |
这里我特别想提醒的一点是:订单表里冗余了phone、address、consignee、user_name这些字段。为什么不在下单时直接关联地址簿表和用户表,而要把这些信息复制一份到订单表?因为订单是交易快照。用户下单后,地址簿可能被修改,用户名可能被修改,甚至地址被删除,但订单里记录的必须是下单那一刻的收货信息,后续商家配送、售后对账都要以订单里的快照为准。在实际开发中,这种“冗余字段”不是设计缺陷,而是为了防止历史数据被后续变更污染。
订单明细表order_detail,字段相对简单:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar | 菜品名称 |
| image | varchar | 菜品图片 |
| order_id | bigint | 订单id |
| dish_id | bigint | 菜品id |
| setmeal_id | bigint | 套餐id |
| dish_flavor | varchar | 菜品口味 |
| number | int | 数量 |
| amount | decimal | 单价 |
同样,这里的name、image、dish_flavor也是快照。菜品名称、价格、图片都是可以改的,但用户下单时看到的是什么,订单明细里就必须记录什么。比如用户点了一份“鱼香肉丝”,口味选“微辣”,几分钟后商家把“鱼香肉丝”改名为“鱼香肉丝Plus”,价格也涨了,但订单里依然要显示鱼香肉丝、微辣、下单时的价格。这就是明细表不直接关联dish表的根本原因。
2.2 购物车表与订单实体之间的数据来源
下单的数据来源是购物车表shopping_cart,它的设计也很典型:
- id、name、image、user_id、dish_id、setmeal_id、dish_flavor、number、amount、create_time
注意这里同样冗余了name、image、amount。购物车里存这些字段,是为了在购物车列表页直接展示,不用每次都去联表查菜品表。但下单时不能直接用购物车里的amount作为订单金额,因为购物车里的价格可能已经过期了。也就是说,购物车表只是“用户预选清单”,真正下单时要以菜品表的当前价格为准重新计算。唯一的例外是套餐,套餐表里也有price,需要单独查询。
所以你在写Service时,遍历购物车列表时,对每个菜品要重新查一次dish表,拿到最新的status和price;对套餐要重新查setmeal表。这个细节如果不做,等订单生成后复盘发现金额和菜品管理端的最新价格对不上,就会很被动。
2.3 订单号生成规则
苍穹外卖课程里生成订单号的方式是:System.currentTimeMillis()+ 随机数或者用时间戳拼接用户id。我更推荐一种实际项目中更稳妥的生成方式:使用时间戳 + 随机数 + 用户标记,保证在同一毫秒内不同用户不会冲突。当然如果真正做高并发订单号,需要引入雪花算法,但苍穹外卖这个项目并发量完全不需要,用UUID或者时间戳加随机序列就够了。课程里用到的生成方式通常是类似这样:
String number = String.valueOf(System.currentTimeMillis()) + RandomUtil.randomNumbers(4);这里有个小坑:如果同一毫秒内两个用户同时下单,随机数又恰巧相同,订单号会重复吗?概率极低,但为了保险,可以在订单号里加上用户id后几位,或者直接用UUID去掉横线转大写。对于学习项目,用时间戳+随机数即可,但如果你要做简历项目,建议把这个订单号设计讲清楚,能加分。
3. 实操过程与核心环节实现
3.1 查看接口文档,明确前端传参格式
day08的需求文档里,前端提交订单的接口路径一般是POST /user/order/submit,请求参数大致长这样:
{ "addressBookId": 123, "payMethod": 1, "remark": "不要辣,多放葱", "estimatedDeliveryTime": 1737000000000, "deliveryStatus": 1, "tablewareNumber": 2, "tablewareStatus": 1, "packAmount": 2, "amount": 45.5 }注意,前端会把计算好的总金额amount也传过来,但后端绝不能直接信任这个值。下单时后端会重新计算,下面会看到。
对应的返回结果,需要包含订单id和订单号,方便前端跳转到支付页面:
{ "code": 1, "msg": "success", "data": { "id": 123, "orderNumber": "17369999999991234", "orderAmount": 45.5 } }3.2 Controller层:参数接收与基础校验
Controller层写起来很简单,但要注意路径安全和用户身份获取。在苍穹外卖项目中,用户登录后会把用户id存在ThreadLocal中,通过BaseContext.getCurrentId()来拿。这是一个非常关键的设计点,后面很多地方都要用到。
@PostMapping("/submit") @ApiOperation("用户下单") public Result<OrderSubmitVO> submit(@RequestBody OrdersSubmitDTO ordersSubmitDTO) { log.info("用户下单参数:{}", ordersSubmitDTO); OrderSubmitVO orderSubmitVO = orderService.submitOrder(ordersSubmitDTO); return Result.success(orderSubmitVO); }DTO里要加参数校验注解,比如@NotNull标注addressBookId和payMethod,避免空指针。很多同学在Controller层不做校验,结果Service层一取地址簿id直接getById,查出来是null,然后继续getPhone,直接炸出一堆难看的异常。所以Controller层做基础防呆是必要的。
3.3 Service层:事务控制下的完整下单逻辑
Service层是下单功能的核心,直接上代码,这是一个基于苍穹外卖项目常规方案的实现,我加上了比较完整的注释:
@Override @Transactional public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) { // 处理异常情况:地址簿不存在、购物车为空 AddressBook addressBook = addressBookMapper.getById(ordersSubmitDTO.getAddressBookId()); if (addressBook == null) { throw new OrderBusinessException("地址簿为空"); } Long userId = BaseContext.getCurrentId(); ShoppingCart shoppingCart = new ShoppingCart(); shoppingCart.setUserId(userId); List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart); if (shoppingCartList == null || shoppingCartList.isEmpty()) { throw new OrderBusinessException("购物车为空"); } // 构造订单主表数据 Orders orders = new Orders(); orders.setNumber(String.valueOf(System.currentTimeMillis()) + RandomUtil.randomNumbers(4)); orders.setStatus(Orders.PENDING_PAYMENT); // 待付款 orders.setUserId(userId); orders.setAddressBookId(ordersSubmitDTO.getAddressBookId()); 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.setTablewareNumber(ordersSubmitDTO.getTablewareNumber()); orders.setTablewareStatus(ordersSubmitDTO.getTablewareStatus()); orders.setPackAmount(ordersSubmitDTO.getPackAmount()); // 计算订单金额,遍历购物车时用最新菜品价格 double totalAmount = 0.0; int totalNumber = 0; List<OrderDetail> orderDetailList = new ArrayList<>(); for (ShoppingCart cart : shoppingCartList) { OrderDetail orderDetail = new OrderDetail(); orderDetail.setName(cart.getName()); orderDetail.setImage(cart.getImage()); orderDetail.setDishFlavor(cart.getDishFlavor()); orderDetail.setNumber(cart.getNumber()); // 如果是菜品,查询最新价格和状态 if (cart.getDishId() != null) { Dish dish = dishMapper.getById(cart.getDishId()); if (dish == null || dish.getStatus() == 0) { throw new OrderBusinessException("菜品 " + cart.getName() + " 已下架"); } orderDetail.setDishId(cart.getDishId()); orderDetail.setAmount(dish.getPrice()); } // 如果是套餐 else if (cart.getSetmealId() != null) { Setmeal setmeal = setmealMapper.getById(cart.getSetmealId()); if (setmeal == null || setmeal.getStatus() == 0) { throw new OrderBusinessException("套餐 " + cart.getName() + " 已停售"); } orderDetail.setSetmealId(cart.getSetmealId()); orderDetail.setAmount(setmeal.getPrice()); } totalAmount += orderDetail.getAmount() * cart.getNumber(); totalNumber += cart.getNumber(); orderDetailList.add(orderDetail); } orders.setAmount(totalAmount + ordersSubmitDTO.getPackAmount()); orders.setNumber(orderDetailList.size()); // 这里其实应该存总份数还是总种类数,要注意 orderMapper.insert(orders); // 批量插入订单明细 if (!orderDetailList.isEmpty()) { for (OrderDetail od : orderDetailList) { od.setOrderId(orders.getId()); } orderDetailMapper.insertBatch(orderDetailList); } // 清空购物车 shoppingCartMapper.cleanByUserId(userId); // 封装返回VO OrderSubmitVO orderSubmitVO = OrderSubmitVO.builder() .id(orders.getId()) .orderNumber(orders.getNumber()) .orderAmount(orders.getAmount()) .orderTime(orders.getOrderTime()) .build(); return orderSubmitVO; }这段代码里有几个小地方特别容易踩坑,我一个个说。
第一个坑:orders.setNumber(orderDetailList.size()),这句是我故意写出来的错误示范。Orders实体里有个number字段,是订单号用的String类型,而OrderDetail里也有number,是数量。但Orders类中可能还有一个字段表示总份数和总种类数。很多同学会搞混,把明细数量直接set到订单主表的某个int属性上。实际上在苍穹外卖的Orders实体里,没有“总种类数”这个字段,但有一个totalNumber之类的字段,如果你在开发中给订单表扩展了total_number字段,就应该把购物车中所有菜品的数量累加(totalNumber变量)存进去,而不是存orderDetailList.size()。上面代码我注释已经提醒过了,别踩。
第二个坑:订单主表插入后,要立刻拿到自增主键id。这要求Mapper的insert方法上必须加@Options(useGeneratedKeys = true, keyProperty = "id"),或者XML中配置keyProperty="id"。如果忘了配,orders.getId()永远返回null,下面明细表设置orderId时全是null,数据库直接报错。苍穹外卖的Mapper接口中一般已经加了这个注解,但如果是你自己写的,一定要检查。
第三个坑:插入订单明细时,如果明细列表很大,用单条insert循环插入性能很差。MyBatis支持foreach批量插入,但要注意批量插入的SQL长度限制,MySQL默认max_allowed_packet可能有限制。外卖订单一般就几个菜,没太大问题,但如果是B端大订单,建议分批插入,每批几百条。
3.4 Mapper层:批量插入与清空购物车的SQL实现
OrderDetailMapper中的批量插入,常见写法如下:
<insert id="insertBatch"> insert into order_detail (name, image, order_id, dish_id, setmeal_id, dish_flavor, number, amount) values <foreach collection="orderDetailList" item="od" separator=","> (#{od.name}, #{od.image}, #{od.orderId}, #{od.dishId}, #{od.setmealId}, #{od.dishFlavor}, #{od.number}, #{od.amount}) </foreach> </insert>注意这里的collecton名称必须和Mapper接口方法的参数名一致。如果接口方法签名是void insertBatch(List<OrderDetail> orderDetailList);,XML里的collection就是orderDetailList。如果加了@Param注解,就要用@Param里的名字。这个细节报错时特别隐蔽,很多人搞半天发现是参数名不一致。
清空购物车的SQL很简单:
<delete id="cleanByUserId"> delete from shopping_cart where user_id = #{userId} </delete>这里有个并发问题后面详细说:如果用户提交订单后,又在另一个设备上往购物车加了菜,那么清空购物车时会把加的新菜一起删掉吗?理论上会有这个逻辑瑕疵。更严谨的做法是只删除“本次下单涉及的购物车记录”,按dish_id和setmeal_id、dish_flavor等条件精准删除。但课程里为了简单,直接按userId清空。你如果做项目优化,建议改成按明细条件删除。
3.5 金额计算与精度问题
再单独说一下金额计算。代码里用double计算,在财务上是禁区。因为double有精度丢失问题,比如0.1 + 0.2可能等于0.30000000000000004,订单金额一旦出现这种数字,入库后对账就会崩。在学习阶段用double问题不大,但如果想写进简历或者做真实项目,订单金额应该用BigDecimal,并且在数据库里用decimal类型。
我在做苍穹外卖时,把所有金额都给改成了BigDecimal,Service层也用BigDecimal计算。改完后发现一个好处:当你把订单金额和支付回调的金额做对比时,直接用equals比较都不会出问题,如果用double,还要设置精度比较,非常麻烦。
具体改造点是:DTO里的amount、packAmount字段改成BigDecimal;实体类里的amount、packAmount改成BigDecimal;Mapper的resultMap和insert语句不要动,因为MyBatis会自动处理decimal到BigDecimal的映射。计算时:
BigDecimal totalAmount = BigDecimal.ZERO; totalAmount = totalAmount.add(orderDetail.getAmount().multiply(BigDecimal.valueOf(cart.getNumber()))); orders.setAmount(totalAmount.add(ordersSubmitDTO.getPackAmount()));建议你从一开始就用BigDecimal,省得后面回踩。
4. 高并发下的重复下单与事务边界问题
如果你只是跟着课程把代码敲一遍,跑通一次普通下单,其实不算难。难的是要想清楚一个问题:用户手一抖,点了十次提交按钮,会生成十张订单吗?大多数同学写出来的代码都会。下面重点说说这个。
4.1 重复提交的三种常见场景
重复下单有三种典型场景:
- 用户快速连点提交按钮,同一次业务意图发出多个请求;
- 用户在不同页面重复操作,比如先提交了一次,然后返回购物车又提交一次,但其实第一次已经下单了;
- 网络重试导致同一请求被发送多次,比如前端超时后自动重试,或者后端重试机制误触。
对于三种场景,前端的按钮置灰可以挡住一大半问题,但后端必须兜底。因为专业的测试或攻击者可以绕过前端直接调接口。
4.2 基于数据库的唯一约束做幂等
最简单的方案是引入“业务订单号唯一约束”。比如在下单请求中让前端生成一个唯一的token,或者用userId + 一个业务维度(比如同用户同购物车同一时间段)作为唯一维度。在order表里增加一个唯一索引,比如uk_user_biz_token,插入时如果冲突,数据库会报DuplicateKeyException,此时捕获异常,返回“订单已提交”即可。
但苍穹外卖的订单表没有预留这样的字段。所以实际项目里更常见的是在Redis里做分布式锁或幂等标记。
4.3 用Redis做下单防重的实操方案
在用户点击提交订单时,可以先根据userId生成一个唯一的锁key,例如order:submit:userId:123,使用Redis的SETNX命令,设置过期时间比如15秒。如果设置成功,说明该用户当前没有正在提交的订单请求,可以继续执行下单流程;如果设置失败,说明用户正在提交,直接返回“正在提交中,请勿重复操作”。
伪代码如下:
String lockKey = "order:submit:" + userId; Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(15)); if (!Boolean.TRUE.equals(success)) { throw new OrderBusinessException("订单正在提交中,请勿重复操作"); } try { // 执行下单逻辑 } finally { redisTemplate.delete(lockKey); }这个方案能解决同一用户的重复点击问题。但它也有一个问题:如果下单逻辑执行超过15秒,锁提前过期,后续请求又可以进来了。所以过期时间要设置合理,或者使用Redisson的看门狗机制自动续期。对学习项目来说,15秒足够,而且外卖下单接口正常情况下几百毫秒就返回了。
这里补充一个更精简的方案:如果不想引入Redis锁,可以在数据库层面用乐观锁,比如给购物车表加一个版本号或者交易状态字段,下单前先更新状态,只有更新成功的那个请求才能往下走。但这个改动比较大,学习阶段不推荐。
4.4 事务边界与异常回滚
@Transactional注解默认只回滚RuntimeException,而自定义异常OrderBusinessException一般继承RuntimeException,所以可以正常触发回滚。但要注意,如果你在Service里catch了异常然后自己处理,Spring事务就感知不到异常,也就不会回滚。我在第一次写下单时就犯过这个错误:在循环中判断菜品状态时,catch了一个Exception并跳过,结果订单主表依然插入成功,但明细少了几条,数据直接错了。
正确的做法是:所有业务校验失败都直接抛异常,交给事务管理器统一回滚。如果你想捕获异常做日志记录,也要在catch块里手动设置TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),强制回滚。
还要注意事务的边界:下单涉及orderMapper.insert、orderDetailMapper.insertBatch、shoppingCartMapper.cleanByUserId三个写操作,任何一步失败都要回滚,所以@Transactional必须加在Service层的公开方法上。为什么不加在Controller层?因为Controller不是Spring管理的bean,而且事务粒度太大也不好。
4.5 数据一致性的最终检查
下单完成后,应该做一次数据一致性检查:订单金额是否等于所有明细金额之和加上打包费,订单状态是否是待付款,购物车是否已清空。这些检查不一定都要写在业务代码里,但写完功能后,你可以打开数据库手工核对一下。我强烈建议你在开发阶段写一个简单的JUnit测试,模拟下单流程,然后断言三张表的数据。测试能帮你尽早发现问题,比手动点接口高效得多。
5. 常见问题与排查技巧实录
5.1 下单后订单表有数据,但订单明细为空
这个问题的原因十有八九是orderDetailMapper.insertBatch没有执行成功,但事务没有被正确触发回滚。排查步骤:先看控制台SQL日志,打印出MyBatis的执行SQL;然后检查批量插入的Mapper XML,看collection名称是否和接口参数名一致;再看明细实体中orderId有没有拿到值。如果orders.getId()返回null,就是insert主键回填配置漏了。
5.2 下单时报“购物车为空”,但前端明明有数据
这个问题一般不是代码逻辑错误,而是用户id获取错了。BaseContext.getCurrentId()是从ThreadLocal中取用户id,如果你在拦截器里没有正确解析JWT并放入ThreadLocal,或者Service中取到的不是当前登录用户,那购物车自然查不到数据。排查时,先确认前端是否携带token,再确认拦截器是否放行了/user/order/submit这个路径,最后在Service里打个日志把userId打出来,对比数据库里的user_id。
5.3 金额计算为0或小数位不对
如果订单金额始终是0,大概率是购物车列表为空或者amount字段为null。另一个常见问题是double计算导致的科学计数法显示,比如2.0E-5之类的。如果你用double接受前端金额,又做加减乘除,很容易出现这种诡异数字。我建议直接全部换成BigDecimal,别犹豫。
5.4 下单成功后,购物车里的菜还在
清空购物车的SQL没生效。检查一下cleanByUserId方法的XML,看user_id条件是否拼对了,另外确认事务提交了吗。如果你在Service里先清空购物车,然后后续代码抛异常,事务回滚后购物车就还是满的。这其实是正常的,说明回滚生效了。但如果你在清空购物车之后没有执行业务操作,直接返回,购物车还是满的,那就要检查Mapper接口是否真的执行了delete。还有一种情况:用户id类型不一致,比如购物车表的user_id是bigint,但你传了一个Long,MyBatis会自动转换,一般不会出错,但如果你传的是String类型的id,就可能查不到。
5.5 地址簿id存在,但是查出来是null
这个错误通常是因为Controller层接收的参数名与前端不一致。比如前端传addressBookId,后端DTO字段写的address_book_id,或者没有加@JsonProperty注解,导致Spring无法绑定。解决方法是前端抓包看真实请求参数,然后统一字段命名风格,推荐使用驼峰命名,并在application.yml中开启map-underscore-to-camel-case: true。
5.6 下单接口报500,日志里有空指针
最常见的空指针出现在addressBook.getPhone()这一行。虽然前面判断了addressBook != null,但如果数据库里address_book表的phone字段是NULL,那就会在设置订单phone时出现空指针。建议实体字段都设置默认值,或者在下单时做兜底:phone为空就返回“请完善收货人电话”。真实项目中,地址簿的校验逻辑会更严格,不只是查存在性,还要查字段完整性。
5.7 订单号重复引发数据库唯一索引冲突
如果你给order表的number字段加了唯一索引,而生成本地订单号的方式是时间戳+4位随机数,在并发稍微大一点时就有可能撞号。解决方法是把随机数位数扩展到6位以上,或者用雪花算法。课程里用的订单号是字符串类型,一般不会强制唯一索引,但你在做项目扩展时如果加了,就要考虑并发。
6. 实操总结与个人体会
写完用户下单,苍穹外卖这条主链路算是串通了一半。我在做day08时,最深刻的体会是:一个看似简单的提交接口,真正做好要比想象中多考虑好几层。从Controller的参数校验,到Service的金额重算,再到事务回滚、防重提交,每一步都隐藏着真实业务里会遇到的痛点。
给你一个实用建议:在做下单功能时,先不要急着写代码,而是拿一张纸,画出“请求进来 → 校验地址 → 校验购物车 → 查询菜品 / 套餐 → 计算金额 → 插入订单 → 插入明细 → 清空购物车 → 返回结果”这条时间线,然后逐个节点去思考可能出现的数据问题、并发问题、异常问题。这个习惯能让你在这个过程中少走很多弯路。
另外,建议你把day08之后要做的“订单支付”环节也提前想一下。下单生成了待支付订单,支付回调回来时怎么更新订单状态?如果支付成功但订单已经被取消怎么办?这些问题现在脑子里有个轮廓,后面做起来会顺畅得多。我个人的经验是,把这个模块完整的业务闭环跑通之后,再去回头看表结构设计,会比单纯跟着视频敲代码理解深刻得多。