我学苍穹外卖学到第10天,正好到了整个项目里最让我兴奋的一段——管理端那堆增删改查终于告一段落,从这天开始切入用户端真正的核心链路:从购物车勾选商品到生成订单、模拟支付,再到订单状态流转。day10的重头戏就是“用户下单”这个模块,前面9天学的JWT、公共字段自动填充、Redis缓存、事务处理,全都会在这段业务里串起来。这篇总结我打算按实际敲码顺序来写,把课堂笔记里没细讲的边界条件和踩坑点补出来。正在跟课的人可以当第二天的复习材料,没跟过课的人也能直观看到一个外卖项目的下单模块到底是怎么设计的。
1. 学完第9天之后,day10要啃的是什么
1.1 苍穹外卖的模块地图:管理端和用户端的分界线
苍穹外卖整体分成两大块。管理端是给门店员工用的,功能包括员工登录、员工管理、分类管理、菜品管理、套餐管理、店铺营业状态、订单管理;用户端是给小程序用户用的,包括微信登录、浏览菜品、购物车、地址簿、提交订单、支付、订单查询。这个划分不是随便分的,它对应的是“后台管内容,前台做交易”的典型业务结构。
跟我学的这个版本里,第9天结束时管理端基本完工,用户端的登录和购物车也已经跑通了。为什么要把用户下单放到第10天?因为下单是整个项目里第一个真正涉及“多表写入 + 状态变化 + 外部交互”的业务。它不像菜品管理那样只对一张表做CRUD,而是同时要动购物车表、订单主表、订单明细表,还要处理支付状态和订单状态。换句话说,前面那些模块是在教你怎么写单个接口,day10开始教你怎么写一条业务链路。
1.2 day10的目标清单:从购物车到支付成功的完整链路
这一天我的任务拆开看其实很清楚:
- 购物车确认页的数据准备(购物车列表 + 地址簿列表)
- 提交订单接口:校验地址和购物车、构造订单主表、构造订单明细、清空购物车
- 模拟支付:把订单从“待付款”推到“待接单”
- 订单号生成规则和状态字段的设计
清单看起来不多,但每一步都有坑。尤其是“提交订单”这个接口,它不像之前的接口那样只对一个表操作,事务边界如果没想清楚,后面查数据的时候会发现各种灵异现象:订单主表插进去了,明细表是空的;购物车被清空了,但订单没生成;订单生成了,但前端拿到的订单id是错的。这些问题我这一整天基本都遇到了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 下单前置数据:购物车和地址簿是怎么准备的
2.1 购物车表为什么放在MySQL,而不是Redis
很多人一看到“购物车”三个字就下意识觉得应该用Redis,毕竟Redis的Hash结构天生适合存购物车,而且速度快、过期时间也方便。但苍穹外卖这个项目里,购物车表是放在MySQL里的,表名shopping_cart,核心字段大致这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 用户id |
| dish_id | bigint | 菜品id,可空 |
| setmeal_id | bigint | 套餐id,可空 |
| name | varchar | 冗余的菜品/套餐名称 |
| image | varchar | 冗余的图片地址 |
| number | int | 数量 |
| amount | decimal | 单价 |
| create_time | datetime | 加入购物车的时间 |
为什么放在MySQL?最直接的原因是:购物车数据和订单流程强关联,提交订单时要把购物车数据读出来做订单明细,然后在同一个事务里清空购物车。如果购物车放在Redis,那订单的service方法里就要同时操作MySQL和Redis,事务就不好控制了——MySQL的事务管不到Redis,万一订单写成功了、Redis购物车清空失败,用户下次打开购物车会发现已经下过单的商品还在里面,非常尴尬。
当然Redis也不是完全不能用,真实高并发场景下购物车放Redis是很常见的做法,但需要一个补偿机制或者可靠的消息队列来保证最终一致性。苍穹外卖作为教学项目,把购物车放MySQL是更稳妥、更好理解的选择,也方便初学者用一套事务搞定所有写操作。
这个表还有一个值得注意的点:name、image、amount这些信息是从菜品表或者套餐表冗余进来的。为什么不直接只存dish_id,需要展示的时候再去查菜品表?因为购物车列表页面需要一次性展示名称、图片、价格、数量,如果只存id,每次查列表都要join菜品表和套餐表,还要判断到底是菜品还是套餐,SQL写起来非常啰嗦。冗余字段就是拿空间换查询效率,这个思路在订单明细表里会再次出现。
2.2 地址簿的默认地址逻辑:两个update的事务问题
地址簿表address_book,核心字段包括收货人、手机号、省市区、详细地址、标签,还有一个关键字段is_default,0表示非默认,1表示默认。一个用户最多只能有一个默认地址,这个约束不是靠数据库唯一索引实现的,而是靠业务代码控制的。
设置默认地址的mapper要做两个update:先把当前用户所有地址的is_default置为0,再把目标地址的is_default置为1。为什么要分两步?如果直接只更新目标地址为默认,而用户之前已经有一个默认地址,那就会出现两个is_default=1的地址,前端展示的时候就会判断混乱。如果只把原来的默认清掉而不设置新的默认,用户就会突然没有默认地址。
这两个update必须在同一个事务方法里执行,否则第一步成功了第二步失败了,用户就失去了默认地址。我当时第一次写的时候没加@Transactional,测试时倒也没出问题,因为本地数据量小,两步操作间隔极短。但后来我模拟人为把第二步的SQL改成语法错误,就发现第一步已经把默认地址清掉了,数据库里没有默认地址,二次下单的时候联调出现问题。所以这种“先清后设”的逻辑,事务是必须的。
2.3 结算页回显:后端视角的“数据拼装”
下单前的结算页,前端要展示购物车商品列表、收货地址列表、配送费和总金额。后端需要提供两个查询接口:一个查当前用户的购物车列表,一个查当前用户的地址簿列表。这里没有单独设计一个“结算页聚合接口”,前端拿到这两个列表自己拼页面,简化了后端接口设计。
有一点要特别注意:苍穹外卖的下单是默认一键下单全部购物车商品,不支持多选。所以提交订单时,前端参数只需要传一个addressBookId,购物车商品不需要传。有些人习惯性地想传一个商品id数组,反而是画蛇添足。
