写苍穹外卖项目做到第八天,前面已经把员工端、分类、菜品、套餐、购物车都过了一遍,今天终于轮到C端真正跑通交易闭环的关键一步:地址簿、用户下单、订单支付。这三个功能放一起其实是有逻辑的——地址簿是下单的数据准备,下单是把购物车的内容固化成订单,支付则是让订单状态真正往前推进。三者串起来,一张订单才能从“提交”状态走到“已支付”,这也是整个外卖项目里业务链路最长、事务和并发最容易出问题的一段。
这篇就按我实际开发的顺序来聊:先讲地址簿怎么设计最省事,再讲下单接口的核心链路怎么拆,最后讲支付这块在没企业资质、没沙箱环境的情况下怎么落地。每一步都会把表结构、接口逻辑、关键代码和踩过的坑一起写出来,做苍穹外卖或者类似外卖项目卡在这一天的同学,可以直接照着走。
1. 地址簿功能:先把“收货信息”这块地基打好
地址簿听起来很简单,就是CRUD,但它有一个隐藏的复杂度:所有数据都必须按当前登录用户隔离。也就是说,用户A加的地址,用户B绝对不能查到。如果你是从员工端一路写过来的,容易惯性思维直接查全表,这是第一个要改掉的习惯。
1.1 数据模型设计:一张表就够,但字段别省
地址簿表我用的就是项目里标准的 address_book 表,核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| user_id | bigint | 用户id,逻辑外键 |
| consignee | varchar | 收货人姓名 |
| sex | varchar | 性别,用于部分配送场景展示 |
| phone | varchar | 手机号 |
| province_name | varchar | 省名称 |
| city_name | varchar | 市名称 |
| district_name | varchar | 区名称 |
| detail | varchar | 详细收货地址 |
| label | varchar | 标签,比如“家”“公司” |
| is_default | tinyint | 是否默认地址,0否1是 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
| create_user | bigint | 创建人 |
| update_user | bigint | 修改人 |
这里有个细节:省市区的名称字段应尽量直接存名称,不要只存code。下单时地址信息会做快照存入订单表,后续展示和打印小票都依赖这个名称。如果只存区域码,到时候还得回查行政区划表,平白增加复杂度。
关于 is_default,当初设计时纠结过要不要单独建一张用户默认地址表,后来放弃了。理由很简单:一个用户最多维护十几个地址,全表扫描成本可忽略,直接在地址簿表里加一个标记位就行。每次设置默认地址时,用一条UPDATE先把该用户所有地址置为非默认,再把目标地址置为默认,两步操作放在同一个事务里,逻辑清晰。
1.2 接口开发顺序与关键逻辑
地址簿接口一共六个,开发顺序建议按这个来:
- 新增地址
- 查询当前用户所有地址(列表)
- 查询默认地址
- 修改地址
- 删除地址
- 设置默认地址
先说新增,这是最基础但陷阱最多的一个。Controller层接收前端传来的JSON,Service层第一件事就是从ThreadLocal里取当前登录用户的ID,手动set到addressBook对象上。千万不能信前端传的userId,前端传什么伪装成什么,接口安全性在这个环节最容易翻车。
java复制@PostMapping
@ApiOperation("新增地址")
public Result save(@RequestBody AddressBook addressBook) {
addressBook.setUserId(BaseContext.getCurrentId());
addressBook.setCreateTime(LocalDateTime.now());
addressBook.setUpdateTime(LocalDateTime.now());
addressBook.setCreateUser(BaseContext.getCurrentId());
addressBook.setUpdateUser(BaseContext.getCurrentId());
addressBookService.save(addressBook);
return Result.success();
}
列表查询就简单了,按 user_id 查出来后按更新时间倒序排,新加的地址排前面,这个排序很重要。用户维护地址时,最常用的往往是最近添加的那几个,倒序能让前端少一次滚动。
默认地址的查询要单独写一个接口,不能靠前端在列表里自己挑。原因有二:第一,下单页需要快速回显默认地址,单独接口一次请求就能拿到;第二,如果用户还没设置过默认地址,这个接口要返回null,前端要做兜底提示“请先添加地址”。
设置默认地址的SQL我直接写在XML里,两条语句:
xml复制<update id="setDefaultByUserId">
update address_book set is_default = 0 where user_id = #{userId}
</update>
<update id="setDefaultById">
update address_book set is_default = 1 where id = #{id}
</update>
Service层用 @Transactional 包住这两个操作,防止只清了默认没设上新默认,导致用户没有默认地址的情况发生。
删除地址前要判断一下:如果删的是默认地址,直接删就行,不需要把默认标记转给其他地址。用户下次下单时系统会提示选地址,不会造成数据异常。这个我一开始还专门写了“删除默认地址后自动把最新地址设为默认”的逻辑,后来觉得多此一举,删掉反而更符合实际产品需求。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用户下单:从购物车到订单表的完整链路
地址簿就绪后,下单接口是今天最核心的业务。它最大的特点不是某一个操作多难,而是要把购物车、地址、订单、订单明细四块数据串起来,任何一环出错都得回滚。这也是我建议把下单逻辑单独成Service的原因,Controller层只做参数接收和结果返回,别掺和业务。
2.1 订单与订单明细的表结构设计
下单涉及两张表:orders(订单主表)和 order_detail(订单明细表)。主表存一次订单的汇总信息,明细表存这一单里每个菜品的快照。
orders表的关键字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| number | varchar | 订单号,用于业务标识 |
| user_id | bigint | 下单用户 |
| address_book_id | bigint | 地址簿id |
| order_time | datetime | 下单时间 |
| checkout_time | datetime | 支付时间 |
| pay_method | int | 支付方式,1微信2支付宝 |
| pay_status | tinyint | 支付状态,0未支付1已支付2退款 |
| amount | decimal | 订单总金额 |
| remark | varchar | 备注 |
| phone | varchar | 收货人手机号(快照) |
| address | varchar | 收货地址(快照) |
| consignee | varchar | 收货人姓名(快照) |
| status | tinyint | 订单状态,1待付款2待接单3已接单4派送中5已完成6已取消 |
| cancel_reason | varchar | 取消原因 |
| rejection_reason | varchar | 拒单原因 |
| cancel_time | datetime | 取消时间 |
| estimated_delivery_time | datetime | 预计送达时间 |
| delivery_status | tinyint | 配送状态 |
| pack_amount | decimal | 打包费 |
| tableware_number | int | 餐具数量 |
| tableware_status | tinyint | 餐具数量状态 |
注意,收货人手机号、地址、姓名这三个字段在orders表里是“冗余”的,它们是从地址簿复制过来的快照。为什么这么设计?因为地址簿里的地址用户随时可能修改删除,但订单一旦生成,收货信息就必须固定下来,否则一个月前的订单地址被用户改了,商家就找不到人送货了。这就是典型的“订单不可变”思想,聊到技术原理时可以说一说。
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 | 单价 |
明细表同样做快照存储。菜品名称、图片、价格都会被复制进来,目的和地址快照一样——防止菜品后续改价、改名、下架,历史订单显示受到影响。这也是外卖系统的一个基本要求。
2.2 下单核心逻辑拆解
下单Service方法我命名为 submitOrder,完整流程可以拆成六步:
第一步,校验地址。根据前端提交的 addressBookId 查出地址记录,查不到直接抛业务异常“地址不存在”。这里要注意:查询时必须带 userId 条件。曾经有个安全漏洞,就是下单时传别人的地址id,也能查到地址,所以要加 AND user_id = #{userId}。
第二步,查询购物车。根据当前userId查购物车列表,如果购物车为空,直接抛异常“购物车为空”,不用继续往下走。这块判断不能省,因为前端逻辑可能有漏洞,用户清空购物车后直接点提交,不该给他生成空订单。
第三步,组装订单主表数据。订单号生成方式我用的是时间戳加随机数:
java复制String orderNumber = String.valueOf(System.currentTimeMillis())
+ String.format("%04d", new Random().nextInt(10000));
这种生成方式在单机高并发下有极小概率重复,但对于教学项目和个人项目足够。真正上线要用分布式ID方案,比如雪花算法。另外金额计算必须用BigDecimal,不能用double直接相加。购物车里的每个item有 amount(菜品单价,BigDecimal类型),总价就是累加每个菜品 amount * number。这个我踩过坑,用double累加后数据库存的是9.9999999999,前端展示直接翻车。
第四步,组装订单明细。遍历购物车列表,把每个item转换成OrderDetail对象。这里要区分菜品还是套餐:如果dishId不为空就存dishId,setmealId置空;反过来亦然。菜品名称和图片直接取自购物车的name和image,这些字段在加入购物车时已经冗余了一份,正好用上。
第五步,批量插入订单明细。MyBatis批量插入用foreach:
xml复制<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>
这里有个细节,批量插入的SQL长度会随着列表变大而变长,MySQL对SQL长度有限制,一个订单最多几十个菜品问题不大,但如果以后做批量功能,要控制单批插入条数。OrderMaster插入后,MyBatis会把自增主键回填到order对象里,这样orderId才能用于明细表的插入。注意insert标签上要加 useGeneratedKeys="true" keyProperty="id",否则拿不到主键。
第六步,清空购物车。调用购物车Mapper删除当前用户所有记录。最后把订单号返回给前端,前端拿着这个订单号去请求支付。
整个方法必须在事务里执行,即Service方法上加 @Transactional。为什么?因为步骤三到步骤六涉及四张表的写操作,如果订单主表插成功了、明细插入失败,或者购物车没清干净,都会出大问题。事务一包,任一环节异常,全部回滚,保证数据一致性。
2.3 下单接口的安全与校验细节
这里有一个很容易被忽略的点:重复点击提交订单。用户在下单页手一抖点了两次提交,如果后端不做任何处理,就会生成两笔一模一样的订单。解决方案有几个:
第一,前端按钮置灰,提交后3秒内禁止再次点击。这是最基础的防重复手段,但不是可靠的,因为恶意请求可以绕过前端直接调接口。
第二,后端做幂等控制。可以在下单接口上接收前端生成的唯一业务流水号(比如UUID),存入Redis,设置过期时间。请求进来先判断Redis中是否已有该流水号,有则说明重复提交,直接返回上一次的订单号;没有则先写入再执行业务逻辑。这种方式实现成本不高,但对个人项目来说有点提前优化了。
第三,利用数据库的唯一索引兜底。比如给订单表的number字段建立唯一索引,重复生成的订单号(理想情况下)会被数据库拒绝。但上面说的时间戳+随机数的订单号在极端并发下可能撞车,所以这属于兜底措施,不是主要方案。
我个人建议是:前端按钮置灰 + 事务保证数据一致性,这两个组合够用。等以后接真实支付再考虑幂等也不迟。
3. 订单支付:模拟微信支付怎么落到数据库里
支付是今天最需要“看菜下饭”的一个模块。真实项目里肯定要对接微信支付的服务商接口,需要商户号、API证书、回调URL域名备案等等,个人开发阶段根本拿不到这些资质。苍穹外卖项目的设计也考虑到了这点,给了我一个很务实的做法:把支付调用抽象成接口,开发环境用模拟实现,生产环境再替换成真实的微信支付SDK。
3.1 支付流程与状态流转
先理一下支付的状态流转:下单时订单状态是 1待付款,支付成功后变更为 2待接单。这个流转在下单Service和支付Service里都要保持一致。支付接口的入参是订单号,不是订单id,这也是业务规范之一,前端拿着订单号来支付,数据库里通过订单号查订单。
支付逻辑拆解:
第一步,根据订单号查询订单。注意,查询条件要带上当前用户id,防止用户A拿用户B的订单号去支付,虽然支付不成功但也要拦截。
第二步,校验订单状态。如果订单状态不是“待付款”,直接抛“订单状态错误”。这里有个隐藏需求:用户可能对已取消的订单发起支付,这时候必须拦截。
第三步,调用支付接口。我在项目里定义了一个 OrderService,里面有一个 pay 方法,内部调用一个专门处理微信支付的 WeChatPayService,在开发环境注入的是一个Mock实现:
java复制@Service
public class MockWeChatPayServiceImpl implements WeChatPayService {
@Override
public String pay(String orderNumber, BigDecimal amount) {
// 模拟真实支付,sleep 200ms,直接返回支付成功
return "SUCCESS";
}
}
这样做的最大好处是:今天是模拟支付,明天你拿到了真实商户号,只需要增加一个新的实现类,在配置中心切换bean的实例,业务代码一行不用改。这就是面向接口编程的意义。
第四步,支付成功后更新订单状态。更新语句很简单:
sql复制update orders set
pay_status = 1,
status = 2,
checkout_time = #{checkoutTime}
where id = #{id} and status = 1
注意where条件里加 and status = 1,这是乐观锁思想:只有订单状态还是待付款时才允许更新成已付款,避免因为并发导致状态被覆盖。如果更新影响行数为0,说明订单已经被其他请求处理过,直接返回失败或提示“订单已支付”。
第五步,回填支付信息。真实支付还要记录微信支付单号、支付流水号等信息,方便对账。模拟阶段我直接存了个固定值,但表结构里预留了字段。
3.2 支付回调与主动查询的取舍
真实微信支付是异步回调机制:用户支付成功,微信服务器会往你的回调URL发一个通知,你的系统收到通知后更新订单状态。但本地开发时,回调URL需要公网可达,个人电脑根本没这个条件。
苍穹外卖项目的做法是“主动查询”加“状态修正”。下单后前端每2秒轮询一次订单状态接口,如果发现订单状态从“待付款”变成“待接单”,就认为支付成功,跳转支付成功页。这种方法实现简单,适合教学项目,也更贴近绝大多数前端工程师的直觉。
我在本地联调时也用这种方式。模拟支付接口支付完成直接改库,前端轮询到新状态,整个流程就闭环了。不过要提醒一句:真实生产环境绝对不能这么做。轮询有延迟,用户体验差;更重要的是,主动查询接口如果暴露给前端,会带来订单状态被恶意刷新的安全隐患。真实服务端应该用回调 + 幂等处理,回调里更新状态时同样要加状态判断,防止重复通知导致重复更新。
3.3 支付后的订单状态与用户端展示
支付完成,订单状态从 1待付款 变为 2待接单,这时候用户端订单列表里能看到这个订单处于“待接单”状态。接下来就是商家端的事:商家接单后状态变为 3已接单,之后派送、完成、取消等状态流转和今天的支付话题关系不大了,但状态枚举值必须提前定义好,前后端共用一套枚举含义,否则对接的时候各说各话。
我在实现时用了一个 OrderStatus 常量类,把所有状态集中管理:
java复制public class OrderStatus {
public static final Integer PENDING_PAYMENT = 1;
public static final Integer PENDING_ACCEPTANCE = 2;
public static final Integer ACCEPTED = 3;
public static final Integer DELIVERING = 4;
public static final Integer COMPLETED = 5;
public static final Integer CANCELED = 6;
}
这里有个经验:状态字段不要用字符串,用数字。数字的可读性虽然差一些,但存储效率高,而且后续加状态不需要改表结构。注释写清楚每个数字的含义就行。
还有支付金额的问题。支付接口调用时,前端会传一个金额,但后端绝不能直接用前端传的金额作为支付金额。正确做法是:从数据库订单里查出真实金额,传给支付接口。如果前端传的金额和数据库不一致,说明有人篡改了前端请求,直接拒绝。这一点在做转账、支付类功能时是铁律,下单金额以服务端计算为准。
4. 常见问题与排查技巧实录
这几天做下来,把遇到的比较典型的问题和排查思路整理一下,很多都是新手容易反复踩的坑,建议直接收藏。
4.1 问题速查表
| 问题现象 | 可能原因 | 排查方向与解决办法 |
|---|---|---|
| 地址簿列表查询为空 | 没从Token取userId,查了全表或查了null | 检查JWT拦截器,确认BaseContext中是否注入了用户ID |
| 新增地址后列表查不到 | user_id没set进去,存的是null | Service层手动setUserId,别信前端传参 |
| 下单提示“地址不存在” | 地址id不属于当前用户 | 查询SQL加 user_id 条件,包含地址id和当前用户id |
| 下单后购物车没清空 | 事务没生效 | 检查Service方法是否被同类内部调用,代理不生效导致Transactional失效 |
| 订单详情菜品为空 | 没有区分菜品和套餐 | 判断dishId和setmealId哪个非空,分别插入 |
| 订单号重复 | 时间戳+随机数并发冲突 | 本地开发概率低,生产用雪花算法 |
| 支付后订单状态没变 | 更新SQL的where条件不匹配 | 检查是否加了 status = 1 条件,以及订单号是否查错了 |
| 金额对不上 | double浮点累加精度丢失 | 全部用BigDecimal,数据库字段用decimal |
| 前端回调地址不通 | 本地环境无公网IP | 用模拟支付,前端轮询替代回调 |
4.2 事务不生效的典型场景
写苍穹外卖这类项目,@Transactional 不生效是我见过最多的问题。最常见的原因有三个:
第一,同类内部调用。Service方法A调用同类方法B,B上有 @Transactional,但A没有。这种情况下,方法B的事务不会生效,因为它是通过this调用的,没有经过Spring代理对象。解决办法是:把事务方法抽到另一个Service或者直接在A上加事务。
第二,异常被try-catch吞掉了。@Transactional 默认只在RuntimeException时回滚,如果代码里catch住了异常并且没有重新抛出,事务就感知不到异常,自然不回滚。这是最坑的,因为代码看起来没报错,但数据就是不对。
第三,数据库引擎不支持事务。MySQL的MyISAM引擎就不支持事务,但一般项目都会用InnoDB,这个需要确认一下建表语句。
4.3 金额计算的精度坑
下单时最容易翻车的地方是金额。购物车里的菜品单价是BigDecimal,但如果你不小心用double相加,比如:
java复制double total = 0;
for (ShoppingCart cart : cartList) {
total += cart.getAmount().doubleValue() * cart.getNumber();
}
跑一次可能没问题,但数据多了就会出现 0.30000000000000004 这种结果。这个值入库到decimal字段时会被四舍五入,但传给前端展示就出错了。
正确写法:
java复制BigDecimal total = new BigDecimal("0");
for (ShoppingCart cart : cartList) {
BigDecimal amount = cart.getAmount().multiply(new BigDecimal(cart.getNumber()));
total = total.add(amount);
}
BigDecimal构造时优先用字符串构造器,不要用 new BigDecimal(0.1),否则同样会有精度问题。
4.4 订单状态字段的并发更新
支付回调时,微信服务器可能会因为网络问题重发回调,同一个订单会被通知两次。如果你的更新逻辑是“无条件更新状态”,第二次回调会把订单状态再次改成待接单,虽然结果一样,但如果有 checkout_time 之类的字段,第一次回调写入的支付时间会被第二次覆盖,虽然值可能一样,但思想上是错的。
解决办法是加where条件:
sql复制update orders set
pay_status = 1,
status = 2,
checkout_time = #{checkoutTime}
where id = #{id} and status = 1 and pay_status = 0
这样当第二次回调进来时,订单状态已经不是1、pay_status已经是1了,更新影响行数为0,代码拿到0就知道是重复回调,直接忽略。这种写法在支付、充值、优惠券核销等所有涉及状态流转的场景中通用,值得形成肌肉记忆。
4.5 定时任务处理超时未支付订单
除了支付成功,还要考虑用户下单后一直不支付的情况。如果不下发取消操作,这些“待付款”订单会一直挂在数据库里占库存。苍穹外卖项目可以加一个简单的定时任务,比如每分钟扫描一次超过30分钟未支付的订单,将其状态改为“已取消”:
java复制@Component
@Slf4j
public class OrderTimeoutTask {
@Scheduled(cron = "0 * * * * ?")
public void processTimeoutOrder() {
LocalDateTime time = LocalDateTime.now().minusMinutes(30);
// update orders set status = 6, cancel_reason = '超时未支付'
// where status = 1 and order_time < time
}
}
如果你用的是Spring Boot,记得在启动类加 @EnableScheduling 注解,否则定时任务不会生效。这个功能不是Day8的硬性要求,但加上会让订单状态闭环更完整,面试时谈到订单模块也是一个加分的点。
5. 一点实操心得
地址簿、下单、支付这三个功能写完,苍穹外卖的核心交易链路就算真正跑通了。我自己做下来的最大感触是:越看似简单的功能,越要抠细节。地址簿就是CRUD,但user_id隔离这个点直接关系到整个C端接口的安全性;下单就是插两张表,但事务、快照、金额精度每一项都会在某个你没想到的时候出问题;支付更不用说了,状态流转的每一环都要有边界条件。
如果你也是照着项目视频在敲,建议不要只满足于把代码录进去跑通。试着把下单Service里每一步SQL打印出来看一遍,看看事务是不是真的包住了所有写操作;再试着把模拟支付替换成一个自己写的延时逻辑,观察前端轮询时订单状态的跳变。多折腾几次,踩过的坑才会真正变成你的经验。
