如果你和我一样正在写苍穹外卖这个项目,Day8 绝对是一道分水岭。前面几天做员工端、分类、菜品、套餐,说白了都是后台管理,逻辑相对直来直去。到了地址簿、用户下单、订单支付这一串,才真正进入 C 端的核心交易链路。这一天的内容拼在一起,等于把“用户从选地址到付款成功”的完整闭环走了一遍,也是后面做订单管理、销量统计的数据源头。
很多人在这一天容易卡住,不是因为单个接口难写,而是三个功能之间的数据关系没理清。地址簿的数据怎么在提交订单时被引用,订单主表和明细表为什么要分开,支付回调到底在改哪张表的状态——这些点一旦通了,代码写起来会很顺;如果不通,就是照着抄都容易抄出隐蔽的 bug。这篇文章我把这三块分开拆,结合我实际写项目时的做法和踩过的坑,尽量说得细一点。
1. 先看清三条业务线的设计思路
1.1 地址簿:一个典型的 C 端业务闭环
地址簿功能表面上很简单,就是增删改查。但它和员工端那些 CRUD 有个本质区别:地址簿的数据是挂在用户维度下的,所有接口都必须从当前登录用户出发,不能查全表。
苍穹外卖这里用的是 Sa-Token 或 JWT 之类的登录方案,前端每次请求会带上 token,后端通过 BaseContext.getCurrentId() 拿到当前用户 ID。这个 ID 是地址簿表的核心过滤条件,几乎所有查询和写操作都要带上。你可以理解为:地址簿不是一张公共通讯录,而是每个用户私有的收货信息集合。
另外地址簿有一个非常关键的字段 is_default,它的业务规则是:每个用户最多只能有一个默认地址。这块如果不做精细处理,很容易出现“设置了好几个默认地址”或者“删除默认地址后系统没有兜底地址”的情况。实际开发中,最常见的做法是先把该用户所有地址的 is_default 置为 0,再把当前这条置为 1,简单粗暴但有效。
1.2 下单与支付:为什么要拆成两个接口 + 事务边界
用户下单和订单支付是两件事,但在业务上是强关联的。我在写的时候最深的感受是:下订单时千万不能直接把支付状态也一起改了,否则后面接支付回调时会非常被动。
苍穹外卖的常规设计是:
- 提交订单接口:只负责把订单主表和订单明细数据落库,订单状态初始化为“待付款”,支付状态为“未支付”。
- 支付接口:调用第三方支付平台创建预支付单,返回支付二维码给前端;支付平台异步通知后端支付成功,后端再更新订单状态。
这里涉及一个关键的事务边界问题:提交订单是写本地数据库,支付是调外部接口。外部调用不应该和本地事务绑在同一个事务里,否则一旦网络超时,本地订单数据会被回滚,用户什么都没有买到,体验很差。正确的做法是:本地订单落库后,事务提交,然后再去调支付接口生成支付参数。如果支付接口调用失败,订单可以保持待付款状态,由前端提示用户重新发起支付。
为什么订单要拆成主表和明细表?因为一个订单可能包含多个商品(多个菜品/套餐),主表存订单的整体信息(收货人、金额、状态、下单时间等),明细表存每个商品的具体信息(菜品名、数量、价格、口味等)。这样设计的好处是:主表数据量小,查询列表快;明细表按 order_id 关联,可以独立扩展;统计某个菜品卖了多少份时,直接聚合明细表就行,不用去解析主表里的文本字段。
1.3 表结构设计:address_book / orders / order_detail
写代码之前,先把三张表的结构理清楚,后面所有的逻辑都会围绕这些字段展开。
地址簿表 address_book 的核心字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 关联用户 ID |
| consignee | varchar | 收货人姓名 |
| sex | varchar | 性别 |
| phone | varchar | 手机号 |
| province_code / province_name | varchar | 省份编码/名称 |
| city_code / city_name | varchar | 城市编码/名称 |
| district_code / district_name | varchar | 区县编码/名称 |
| detail | varchar | 详细收货地址 |
| label | varchar | 标签(如家、公司) |
| is_default | tinyint | 是否默认地址(0/1) |
| create_time / update_time | datetime | 创建/更新时间 |
订单主表 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支付宝) |
| pay_status | tinyint | 支付状态(0未支付 1已支付) |
| amount | decimal | 订单总金额 |
| remark | varchar | 备注 |
| phone / address / consignee | varchar | 收货人信息快照 |
| cancel_reason / rejection_reason | varchar | 取消/拒单原因 |
| cancel_time | datetime | 取消时间 |
| estimated_delivery_time | datetime | 预计送达时间 |
| delivery_status | tinyint | 配送状态 |
| pack_amount | decimal | 打包费 |
| tableware_number | int | 餐具数量 |
| tableware_status | int | 餐具数量状态(1按餐量 2按指定数量) |
订单明细表 order_detail 的核心字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar | 商品名称快照 |
| order_id | bigint | 关联订单主表 ID |
| dish_id / setmeal_id | bigint | 菜品/套餐 ID |
| dish_flavor | varchar | 口味快照 |
| number | int | 数量 |
| amount | decimal | 单价快照 |
| image | varchar | 图片快照 |
注意到没有,订单表和明细表里有很多“快照”字段(name、amount、image、address、phone),它们的值在下单那一刻已经固定了。即便之后菜品改价、地址被删,订单的展示和历史数据都不会变。这是交易系统的一个基本设计原则:业务流水数据不能依赖可变的主数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 地址簿功能核心细节与实操
2.1 接口清单与请求响应设计
地址簿这块后端需要提供下面这些接口,我按实际项目的习惯排了一下:
| 功能 | 请求方式 | 路径 | 说明 |
|---|---|---|---|
| 查询当前用户地址列表 | GET | /addressBook/list | 返回当前用户所有地址,按更新时间倒序 |
| 新增地址 | POST | /addressBook | 传入地址 JSON,自动填充 user_id |
| 查询单个地址 | GET | /addressBook/ | 编辑回显用 |
| 修改地址 | PUT | /addressBook | 根据 id 更新,必须校验属于当前用户 |
| 删除地址 | DELETE | /addressBook/ | 逻辑删除或物理删除 |
| 设置默认地址 | PUT | /addressBook/default | 传入 id,将该地址设为默认 |
前端页面通常是:下单页选择地址、我的页面管理地址、新增/编辑弹窗、默认地址标签。这里的接口设计基本能满足这些场景。
Controller 层的写法比较固定,一个典型的 controller 长这样:
java复制@RestController
@RequestMapping("/addressBook")
@Api(tags = "地址簿接口")
public class AddressBookController {
@Autowired
private AddressBookService addressBookService;
@GetMapping("/list")
@ApiOperation("查询当前用户地址列表")
public Result<List<AddressBook>> list() {
AddressBook addressBook = new AddressBook();
addressBook.setUserId(BaseContext.getCurrentId());
List<AddressBook> list = addressBookService.list(addressBook);
return Result.success(list);
}
@PostMapping
@ApiOperation("新增地址")
public Result save(@RequestBody AddressBook addressBook) {
addressBookService.save(addressBook);
return Result.success();
}
@PutMapping("/default")
@ApiOperation("设置默认地址")
public Result setDefault(@RequestBody AddressBook addressBook) {
addressBookService.setDefault(addressBook);
return Result.success();
}
}
Service 层实现时,要特别注意一个问题:新增第一条地址时,应该自动设为默认地址。很多同学刚开始会漏掉这个逻辑,结果用户添加了第一条地址后,下单页显示“请选择地址”,体验很怪。可以在 save 方法里先查询该用户地址总数,如果为 0,就把 isDefault 置为 1。
2.2 默认地址的处理技巧
设置默认地址的服务实现,建议这样写:
java复制@Transactional
public void setDefault(AddressBook addressBook) {
// 1. 先将当前用户所有地址的 is_default 置为 0
AddressBook update = new AddressBook();
update.setUserId(BaseContext.getCurrentId());
update.setIsDefault(0);
addressBookMapper.updateByUserId(update);
// 2. 再将指定 id 的地址 is_default 置为 1
addressBook.setIsDefault(1);
addressBookMapper.updateById(addressBook);
}
这里有一个隐藏问题:如果传入的 id 不属于当前用户怎么办?比如用户 A 传了用户 B 的地址 id。如果不做校验,用户 A 可能把用户 B 的地址设置为默认地址,虽然最终查询列表还是按 user_id 过滤,一般不会出现越权读取,但更新操作却影响了别人的数据,属于越权写入。建议在更新前先按 id + user_id 查一次,查不到直接抛业务异常。
删除默认地址也需要考虑:如果删除的正好是默认地址,该用户的默认地址就会变成空。比较好的处理是,删除后检查该用户是否还有地址,如果有,就把最新一条设为默认地址。这一块苍穹外卖原项目里不一定处理得很细,但作为实际项目体验,加上这一层会让系统更健壮。
2.3 踩坑:字段映射、逻辑删除与空值校验
我在写地址簿时实际踩过几个坑,分享出来帮大家避雷。
第一个是 isDefault 字段映射。前端传的 JSON 字段名是 isDefault,后端实体类字段如果是 isDefault,MyBatis 默认开启驼峰映射后没问题。但如果表字段写的是 is_default,实体属性是 isDefault,在写 @Insert 或 @Update 的 SQL 时,一定要显式用数据库字段名,否则 MyBatis-Plus 可能把 isDefault 当成 is_default 处理,容易出问题。建议要么统一用 MyBatis-Plus 的 LambdaQueryWrapper,要么在 XML 里把字段写全。
第二个是逻辑删除。地址簿这种数据一般不需要物理删除,尤其是已经关联订单的地址,删了会导致订单里的地址快照失去意义。所以删除操作建议用 update 把 is_deleted 置为 1,查询时统一加 is_deleted = 0 条件。但这里要提醒一句:如果你用了 MyBatis-Plus 的逻辑删除插件,所有查询会自动带上条件,如果你同时手写了 SQL 且没加过滤条件,可能会把已删除的地址也查出来。保持一个项目里只用一个方案,不要混用。
第三个是空值校验。新增地址时,收货人、手机号、详细地址这三个字段必须有。手机号可以做一个简单的正则校验,防止用户乱填导致骑手联系不上。Controller 层可以用 @Valid 配合实体类字段上的注解做校验,但如果不引入额外依赖,手动判断也够用,关键是别漏。
3. 用户下单业务实现
3.1 下单前需要校验什么
下单接口是用户下单业务的核心入口,请求方式为 POST /user/order/submit,核心参数是 OrdersSubmitDTO,包含:
- addressBookId:地址簿 ID
- payMethod:支付方式
- remark:备注
- estimatedDeliveryTime:预计送达时间
- packAmount:打包费
- tablewareNumber:餐具数量
- tablewareStatus:餐具数量状态
Service 层的第一步不是造订单,而是校验。我在写的时候总结出五个必查项:
- 地址簿 ID 是否存在,且属于当前用户。如果地址被删了或者传了别人的地址 ID,直接抛异常。
- 购物车是否为空。如果用户没有加购就提交订单,要提示“购物车为空”。
- 菜品/套餐是否还在售。如果商品已下架,不能下单。
- 菜品/套餐的库存是否足够。苍穹外卖原项目里不一定实现了库存扣减,但作为完整业务,至少要有校验思路。
- 金额计算。后端不能信任前端传来的总金额,应该遍历购物车重新计算一遍,避免用户篡改金额。
这里特别说一下金额计算。前端传来的 amount 只能作为展示参考,后端必须逐项遍历购物车里的菜品,用数据库里的最新价格乘数量,再累加打包费,得到真正的订单金额。否则用户通过浏览器开发者工具把金额改成 0.01 元,后端不校验就亏大了。实操中可以用 BigDecimal 做运算,避免 double 的精度问题。
3.2 订单与明细的批量插入
下单的核心逻辑可以拆成四步:查询购物车、构造订单主表数据、构造订单明细数据、清空购物车。事务必须加在 Service 方法上,保证这四个步骤要么全部成功,要么全部回滚。
订单号生成也是个容易忽略的细节。订单号要保证唯一且对用户友好,常见方案是“时间戳 + 随机数”或者“日期 + 自增序列”。我用的是:
java复制String orderNumber = String.valueOf(System.currentTimeMillis())
+ RandomUtil.randomNumbers(6);
这样生成的是 19 位数字,用户端和后台都能看懂。实际生产环境可能用更复杂的雪花算法,但在项目练习阶段,用时间戳加随机数足够,只要保证并发下不会重复即可。稳妥一点可以加数据库唯一索引兜底,万一重复就重新生成。
代码结构大概这样:
java复制@Transactional
public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) {
// 1. 处理各种业务校验
// 2. 构造 Orders 实体,插入订单主表
// 3. 遍历购物车数据,构造 OrderDetail 列表,批量插入明细表
// 4. 清空当前用户的购物车
// 5. 封装 OrderSubmitVO 返回(订单号、订单金额等)
}
批量插入明细表时,建议用 MyBatis 的 <foreach> 一次性插入,不要一条条 insert。这里贴一个 XML 的示例:
xml复制<insert id="insertBatch">
insert into order_detail
(name, order_id, dish_id, setmeal_id, dish_flavor, number, amount, image)
values
<foreach collection="orderDetailList" item="od" separator=",">
(#{od.name}, #{od.orderId}, #{od.dishId}, #{od.setmealId},
#{od.dishFlavor}, #{od.number}, #{od.amount}, #{od.image})
</foreach>
</insert>
主表插入后,需要回填主键 ID。如果用的是 MyBatis-Plus,save 方法会自动回填;如果手写 XML,要在 insert 标签里加上 useGeneratedKeys="true" keyProperty="id",否则明细表的外键 order_id 就会是 null。
下单完成后,建议在前端做个演示链接,让用户看到下单成功页,上面有订单号、订单金额、预计送达时间,引导用户去支付。这里不要直接把支付二维码一起返回,我试过把两步合并,但一旦支付接口超时,下单结果也被拖垮了,体验很差。下单接口和支付接口分开调用,前端先拿到订单 ID,再调支付接口获取支付参数。
3.3 事务与清空购物车、超时未支付的兜底
下单的事务边界是很多人容易写错的地方。事务注解 @Transactional 只能保证本地数据库的操作,不能保证外部调用。所以清空购物车最好和下单放在同一个事务里,而支付接口调用不放在事务里,等下单事务提交后再调用。这样做的好处是:如果购物车清空失败,订单数据也会回滚,不会出现“订单有了但购物车没清空”或反过来“购物车清了但订单没生成”的情况。
还有一个问题是超时未支付订单的兜底。用户下单后一直不支付,订单会一直占着状态,如果后面做了库存扣减,库存也会被一直占用。实际项目中通常有两种方案:
- 定时任务扫描超过 15 分钟未支付的订单,自动取消并回滚库存。
- 用户再次点击支付时,先判断订单是否超时,超时则不允许支付。
苍穹外卖 Day8 阶段一般不会强制要求你实现定时任务,但建议在支付接口里做一个超时判断:如果当前时间减去下单时间超过了 15 分钟,直接提示“订单已超时,请重新下单”。这算是成本最低、但很见功力的兜底逻辑。
4. 订单支付流程与回调处理
4.1 统一下单、拉起支付、查询结果
订单支付这块,苍穹外卖对接的是第三方支付平台的 Native 支付/当面付这类模式。整个流程是:
- 前端请求支付接口,传入订单号。
- 后端查询订单,校验订单存在且处于待付款状态。
- 后端调用第三方支付平台的“统一下单”接口,传入订单号、金额、商品描述等参数。
- 支付平台返回一个支付二维码链接(或二维码内容)。
- 后端把二维码返回给前端,前端渲染成二维码图片,用户用手机扫码支付。
- 支付平台异步通知后端支付结果,后端更新订单状态。
- 前端轮询订单状态接口,支付成功后就跳转订单详情页。
实际项目中,支付金额必须以数据库里的订单金额为准,不能直接信任前端传参。调用支付平台时传的订单号,建议加上商户号前缀或渠道标识,方便排查问题。
这里有一个关键点:支付平台的回调地址必须是外网可访问的 URL。在本地开发调试时,一般需要用内网穿透工具把本地服务暴露到公网,否则支付平台无法回调到你的本地接口。很多同学卡在这一步,以为是代码问题,其实是回调地址不通。
支付接口的代码骨架大致如下:
java复制public PayVO pay(Long orderId) {
// 1. 查询订单
Orders orders = orderMapper.getById(orderId);
if (orders == null) {
throw new BusinessException("订单不存在");
}
if (orders.getStatus() != 1 || orders.getPayStatus() != 0) {
throw new BusinessException("订单状态异常");
}
// 2. 构建支付参数
// 3. 调用第三方支付平台统一下单接口
// 4. 返回二维码内容给前端
}
4.2 回调逻辑与幂等处理
支付成功后,第三方支付平台会往你配置的回调地址发送一个异步通知,通知里带了订单号、支付金额、支付时间等信息。后端收到通知后,要做两件事:
一是验签。第三方支付平台会用商户密钥对通知参数做签名,后端必须用同样的密钥重新计算签名,比对一致后才认为是合法的支付通知。如果不验签,任何知道回调地址的人都可以伪造支付成功通知,风险极大。
二是幂等处理。支付通知可能因为网络原因发送多次,后端必须保证同一个订单只被处理一次。判断方式很简单:查订单当前支付状态,如果已经是“已支付”,直接返回成功,不再做重复更新。像这样:
java复制Orders orders = orderMapper.getByOrderNumber(outTradeNo);
if (orders != null && orders.getPayStatus() == 1) {
return "success";
}
回调处理的核心是更新订单状态和支付时间:
java复制@Transactional
public String paySuccess(String outTradeNo, String transactionId) {
Orders orders = orderMapper.getByOrderNumber(outTradeNo);
if (orders == null) {
throw new BusinessException("订单不存在");
}
if (orders.getPayStatus() == 1) {
// 已支付,幂等返回
return "success";
}
orders.setStatus(2); // 待接单
orders.setPayStatus(1); // 已支付
orders.setCheckoutTime(LocalDateTime.now());
orderMapper.updateById(orders);
return "success";
}
这里有个地方要特别提醒:回调里更新订单状态时,不要直接拿前端传的订单号去更新,而是先查一次订单对象,再更新。我之前图省事直接 update ... set status = 2 where order_number = ?,结果如果同一个订单被回调两次,第二次就无条件覆盖了订单状态,把骑手已经接单的状态改回待接单,就很离谱。
4.3 支付金额校验、状态流转
回调通知里会带有支付金额,后端必须把这个金额和数据库里的订单金额做比对,只有一致才更新状态。如果不一致,说明支付异常,要记录日志并拒绝处理。
理由很简单:如果订单金额是 100 元,支付平台回调说支付了 10 元,这明显是异常情况(可能是伪造回调、支付参数被篡改、或者业务配置错误),绝不能把订单标记为已支付,否则就形成了资损。
订单状态流转是 Day8 后半段需要重点理解的内容。一个订单从下单到完成,正常路径是:
- 待付款(status=1)→ 支付成功 → 待接单(status=2)
- 商家接单 → 已接单(status=3)
- 开始配送 → 派送中(status=4)
- 用户确认收货 → 已完成(status=5)
异常路径包括:用户取消(status=6)、商家拒单(status=6)、超时未支付自动取消(status=6)。Day8 主要做的是前两步:待付款到待接单。后面商家接单、配送等流程在后续章节才会涉及,但你在写支付回调时,一定要把状态流转的规则想清楚,不要漏掉 checkout_time 的赋值,否则后面统计支付时间时会发现全是 null。
5. 常见问题与排查技巧实录
5.1 问题速查表
这一节我整理了写 Day8 时最容易碰到的几个问题,按排查从易到难排序,方便你对照检查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 新增地址后列表查不到 | 没设置 user_id,查询时按当前用户过滤为空 | 检查 Controller 和 Service 是否调用 BaseContext.getCurrentId() |
| 设置默认地址后,列表出现多个默认地址 | 没有先把其他地址置为非默认 | 检查 setDefault 方法是否两步完成 |
| 下单时提示“地址不存在” | 地址簿 ID 为空或不属于当前用户 | 打印入参,检查前端是否传了 addressBookId |
| 下单后购物车没清空 | 清空购物车逻辑没执行或事务没生效 | 检查 @Transactional 是否加在 public 方法上,是否被同类调用 |
| 订单金额不对 | 后端直接信任了前端传的 amount | 需要从购物车重新计算金额 |
| 支付二维码无法支付 | 支付平台参数错误、回调地址不通 | 查看日志,确认 out_trade_no、amount、回调地址 |
| 回调更新了订单但前端没反应 | 前端没有轮询或回调状态没被正确查询 | 检查订单查询接口返回的状态字段 |
| 支付成功但订单还是“待付款” | 回调没到达、验签失败、处理逻辑抛异常 | 查看支付平台回调日志和后端接口日志 |
| 同一订单被多次支付 | 缺少幂等控制 | 回调里判断 pay_status 是否等于 1 |
5.2 个人实操心得与优化建议
最后分享几个我写这个项目时的习惯,算是比课程文档多走的一步。
第一,所有涉及金额的字段都用 BigDecimal,别用 double。下单金额计算,打包费累加,订单金额比对,这些地方如果用 double,很可能出现 0.1 + 0.2 不等于 0.3 的经典问题。BigDecimal 虽然写法麻烦一点,但能避免很多没法解释的 bug。
第二,凡是操作前需要校验归属的接口,尽量在 Service 层统一做,不要在 Controller 层散落着写。比如改地址、删地址、下单,都要校验当前登录用户和数据归属一致性。统一在一个地方做,后面加权限逻辑时只改一处就行。
第三,支付回调和主动查询要双保险。主动查询是用户扫码后,前端轮询订单状态,发现已支付就跳转;被动回调是支付平台通知后端更新状态。两者必须都能正常工作,且互相不冲突。我在实际调试时遇到过回调正常更新了订单状态,但前端轮询接口查的还是旧状态,后来发现是 Redis 缓存了订单对象没更新,改成查询后刷新缓存才解决。如果你也做了缓存,记得在支付成功后主动失效对应订单的缓存。
第四,日志要打全。下单请求参数、支付请求参数、回调通知参数、异常堆栈,这些都要打印。否则出了问题,你对着黑盒一样的支付流程根本无从下手。我自己的习惯是在回调方法入口先打印一行 info,记录收到通知的完整参数,再打一行处理结果的 info,这样排查问题时直接看日志就能定位是没收到回调、验签失败、还是处理逻辑抛异常。
这几天的内容写完后,你会发现后面做商家端订单管理时,很多接口只不过是在这套状态机上做操作罢了。状态流转理顺了,后面的路就好走了。
