把苍穹外卖这个项目做到 day09 之前,我一直觉得它和普通的增删改查课设没本质区别:建表、写接口、调页面,一个模块一个模块地堆。但 day09 这一天做的是订单,准确说是“从用户下单到商家接单再到配送完成”的完整订单链路。它第一次把用户端、商家端、支付回调、超时任务、实时通知串在了一起,做完这一天,项目才真正有了交易系统的影子。
如果你正在跟苍穹外卖这个项目,或者准备自己撸一套外卖类系统,这一天值得多看几遍。下面这些内容不是把接口清单抄一遍,而是把订单模块背后的设计逻辑、状态机怎么定、WebSocket 推送怎么接、超时取消怎么防误伤,尽量用大白话讲清楚。
1. 为什么订单模块被安排在第九天
1.1 角色从“加菜”变成“串主链路”
前八天做的东西,基本是独立的。店铺管理管店铺表,菜品管理管菜品表,购物车也只需要操作当前登录用户的购物车数据。每个模块之间的耦合度很低,哪怕删掉一个接口,都不太会影响其他功能。
但订单不一样。订单一旦落库,后面跟着的是支付回调、商家接单、骑手配送、用户确认收货这一整条链路。任何一个环节动了,订单状态都要跟着变化。day09 的核心不是教你写两个新增接口,而是让你明白什么叫“一条数据从生到死,被多个角色先后修改”。这个思维转换,比代码本身重要得多。
举个体感最明显的例子:前八天写完一个功能,你只需要关心“入库成功没有”;从 day09 开始,你要关心“这个订单当前是谁在操作、能不能操作、操作完的状态是否符合预期”。一旦状态扭转错了,订单可能被重复接单、被误取消、甚至商户之间互相操作了对方的订单。
1.2 day09 要交付的功能清单和验收标准
按我们当时的开发节奏,day09 结束时至少要跑通下面几件事:
- 用户下单成功,生成订单主表和订单明细表,购物车清空。
- 支付回调模拟成功后,订单从待支付变成待接单。
- 商家端收到“新订单”的实时提醒。
- 商家点击接单,订单从待接单变成已接单。
- 商家开始配送,订单从已接单变成配送中。
- 确认送达后,订单变成已完成。
- 下单后一段时间未支付,订单被自动取消,且不能误伤刚支付成功的单。
这里面其实隐藏了一个完整的验收闭环:从用户下单开始,到商家确认送达结束,中间所有状态都要经得起反复操作和异常场景的考验。day09 的作业如果不按这个标准去验,代码几乎一定会出并发或状态错乱的问题。
2. 订单数据模型:快照思想是第一天就要养成的习惯
2.1 订单主表和明细表的结构设计
订单模块的第一件事不是写接口,是把表建对。我们项目里用的是两张表:订单主表和订单明细表。主表存一次下单的整体信息,明细表存这次买了哪些菜品。下面是简化后的建表结构,字段基本覆盖了常见外卖项目。
CREATE TABLE `order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_number` varchar(32) NOT NULL COMMENT '订单号', `user_id` bigint NOT NULL, `shop_id` bigint NOT NULL, `order_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', `packing_amount` decimal(10,2) DEFAULT '0.00' COMMENT '打包费', `pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额', `status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态 0待支付 1待接单 2已接单 3配送中 4已完成 5已取消', `pay_status` tinyint NOT NULL DEFAULT '0' COMMENT '支付状态 0未支付 1已支付 2退款', `consignee` varchar(50) NOT NULL COMMENT '收货人', `phone` varchar(20) NOT NULL, `address_detail` varchar(255) NOT NULL COMMENT '收货地址快照', `remark` varchar(255) DEFAULT NULL, `create_time` datetime NOT NULL, `pay_time` datetime DEFAULT NULL, `cancel_time` datetime DEFAULT NULL, `finish_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_shop_id` (`shop_id`), KEY `idx_order_number` (`order_number`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单明细表结构大概是这样的:
CREATE TABLE `order_detail` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint NOT NULL, `dish_id` bigint NOT NULL, `dish_name` varchar(100) NOT NULL, `image` varchar(255) DEFAULT NULL, `price` decimal(10,2) NOT NULL, `quantity` int NOT NULL, `amount` decimal(10,2) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;主表和明细表为什么要分开?如果不分,一次下单买五个菜品,就要在主表里存五行,收货人、订单号、金额全部重复一遍,后面改状态还得连带着改好几行,字段一多很容易不一致。拆开之后,主表一行对应一次订单,明细表多行对应订单里的菜品,订单详情页要展示时直接按 order_id 查明细就行。
2.2 为什么必须存地址快照和菜品快照
第一次设计订单表的时候,我踩过一个很典型的坑:订单明细里只存 dish_id,查询时再去菜品表关联名称和价格。表面看没问题,但仔细想就会出事。
用户下单那一刻,菜品价格是 32 元。第二天商家把价格改成 38 元,订单详情页再查菜品表,显示的金额就变成了 38,历史订单金额全乱了。收货地址同理,用户今天下单用的地址,以后可能从地址簿里删掉,但订单作为交易凭证,必须保留当时下单的收货信息。
所以在订单设计里,凡是和交易快照相关的字段,都要把值“复制”进订单表,而不是只存一个外键去实时关联。菜品名称、菜品图片、菜品单价、收货人、手机号、完整地址,都要冗余存储。这不是浪费空间,订单数据本来就是只读为主的历史档案,冗余才能保证它不被后续修改影响。
2.3 订单号生成:别用自增主键顶上去
订单号是个容易被忽略的细节。最早我们直接用订单表的自增 id 给前端展示,结果一条测试订单编号是 1024,用户一看就能猜到平台总订单量,也容易通过订单号枚举别人的订单。更关键的是,将来对接支付、对账、客服查询,订单号几乎都会出现在对外渠道里,必须有独立的可读编号。
day09 阶段不需要上分布式 ID 生成器,用简单方案就够了:
String orderNumber = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + String.format("%02d", orderId % 100) + StringUtils.leftPad(String.valueOf(new Random().nextInt(900) + 100), 3, '0');也就是“年月日时分秒 + 用户 id 后两位 + 三位随机数”。同一个用户在同一秒内连续下单的概率几乎为零,再加三位随机数基本够用。如果要做得更严谨,可以扣掉随机数,改成“时间戳 + 用户 id + 订单自增 id 的低位”,但核心原则是:订单号要独立生成、对外唯一、具备可读性,不能直接把主键裸奔出去。
3. 用户端下单:从购物车到订单的一条事务链路
3.1 下单前校验的四个条件
用户点“提交订单”,后端不能直接 insert,得先过一遍校验,这是交易和普通 CRUD 最大的区别。我们当时梳理了四个必要校验:
- 店铺是否营业。如果店铺已经打烊,用户还能下单,后面没人接单,体验会很差。
- 购物车是否为空。这个最简单,但很多人会忘。
- 菜品是否仍然上架。用户加购物车后,商家可能把某个菜下架了,下单时不能再让它混进订单。
- 是否满足起送价。部分店铺设置了起送金额,总价不够时要在下单前明确提示。
这些校验为什么放在后端而不是前端?因为前端按钮和拦截都只是体验优化,后端接口谁都能直接调。校验逻辑必须让恶意调用者绕不过去,交易数据才安全。
3.2 下单业务实现和代码骨架
下单的核心流程是:校验、算金额、插主表、插明细表、清购物车。这一步必须在一个事务里完成,否则会出现“主表有订单、明细表没数据”的脏数据。Spring 里面一个事务方法就够了:
@Transactional(rollbackFor = Exception.class) public OrderSubmitVO submit(OrderSubmitDTO dto) { // 1. 校验店铺营业状态 Shop shop = shopMapper.getById(dto.getShopId()); if (shop == null || !shop.getStatus().equals(1)) { throw new BusinessException("店铺休息中"); } // 2. 查询购物车 List<Cart> carts = cartMapper.listByUserId(dto.getUserId()); if (carts == null || carts.isEmpty()) { throw new BusinessException("购物车是空的"); } // 3. 计算订单金额 BigDecimal orderAmount = BigDecimal.ZERO; for (Cart cart : carts) { orderAmount = orderAmount.add(cart.getAmount()); } // 4. 保存订单主表 Order order = buildOrder(dto, orderAmount); orderMapper.insert(order); // 5. 保存订单明细 for (Cart cart : carts) { OrderDetail detail = buildOrderDetail(order.getId(), cart); orderDetailMapper.insert(detail); } // 6. 清空购物车 cartMapper.cleanByUserId(dto.getUserId()); return OrderSubmitVO.of(order.getId(), order.getOrderNumber()); }有个细节要注意:@Transactional的 rollbackFor 一定要写成Exception.class。Spring 默认只对运行时异常回滚,如果你抛的是自定义业务异常,而它继承的是 Exception 而非 RuntimeException,事务不会回滚。作业里最常见的坑就是这里,写了个自定义异常结果数据还是入库了,查半天查不出来。
3.3 事务提交后再发消息:一个容易被忽视的时序问题
下单成功后,后面往往跟着两步操作:往 Redis 写超时未支付的 key,以及给商家端推送新订单提醒。很多人图省事,会把这两步直接写进上面的事务方法里。
问题在哪?Redis 操作和 WebSocket 推送都不受数据库事务控制。如果订单的 insert 成功了,后面的明细 insert 失败,整个事务回滚,订单没落库,但 Redis 里那个“等待订单超时检查”的 key 已经写进去了。之后 key 过期被监听,去取消一个根本不存在的订单,虽然 SQL 查询不到订单不会报错,但逻辑上已经留下了脏数据。
稳妥的做法是把“写 Redis”和“发推送”放到事务成功提交之后。最简单的改法是拆分两个方法:
public OrderSubmitVO submit(OrderSubmitDTO dto) { OrderSubmitVO vo = orderSubmitService.doSubmit(dto); // 事务方法 orderTimeoutCache.put(vo.getOrderId()); // 事务提交后再写 return vo; }如果事务方法内部有异步逻辑,也可以用TransactionSynchronizationManager.registerSynchronization注册回调。但 day09 阶段,我建议就用这种“事务方法和后续动作分开编排”的写法,一眼就能看明白执行顺序,也方便以后在上面加别的通知逻辑。
4. 来单提醒:商家端 WebSocket 的设计与坑
4.1 连接管理不能只用 Map 一套到底
用户下单并支付成功后,商家端要实时看到“新订单”,这是靠 WebSocket 做的。整体思路是:商家端浏览器登录后和后端建立一条长连接,后端按 shopId 保存连接会话,支付成功的回调里向对应商家推送消息。
连接管理代码大概是这样的:
public class ShopWebSocketHandler extends TextWebSocketHandler { private static final ConcurrentHashMap<Long, WebSocketSession> SHOP_SESSIONS = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { Long shopId = extractShopId(session.getAttributes()); SHOP_SESSIONS.put(shopId, session); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { Long shopId = extractShopId(session.getAttributes()); if (session.equals(SHOP_SESSIONS.get(shopId))) { SHOP_SESSIONS.remove(shopId); } } public static void sendToShop(Long shopId, String payload) { WebSocketSession session = SHOP_SESSIONS.get(shopId); if (session == null || !session.isOpen()) { SHOP_SESSIONS.remove(shopId); return; } synchronized (session) { session.sendMessage(new TextMessage(payload)); } } }这里有个特别容易踩的坑:afterConnectionClosed里不能无脑SHOP_SESSIONS.remove(shopId)。如果商家的浏览器开了两个标签页,后一个连接覆盖了前一个连接,前一个标签页关闭时触发 close 回调,会把后一个连接也删掉,商家就收不到提醒了。所以必须用session.equals(SHOP_SESSIONS.get(shopId))判断一下,只删“当前连接对应位置”的 session。
4.2 推送时机和消息格式
来单提醒的推送时机不是下单成功,而是支付成功。用户下单但没付款,商家端不应该被频繁打扰;只有钱到了,才算真正的有效订单。
推送消息只发摘要信息,不要把整个订单明细都塞进去:
{ "type": "NEW_ORDER", "orderId": 1024, "orderNumber": "202505081200001234", "amount": 36.50, "createTime": "2025-05-08 12:00:00" }商家端收到提醒后,会刷新订单列表,真正要看完整明细时再去调详情接口。这样设计是为了减少一次推送的数据量,也是为了让提醒接口和详情接口各司其职。
支付回调里推送的代码大概长这样:
public void handlePaySuccess(Long orderId) { Order order = orderMapper.getById(orderId); if (order == null || order.getStatus() != 0) { return; } int rows = orderMapper.updateStatus(orderId, 1, 0); if (rows == 0) { return; } redisTemplate.delete("order:timeout:" + orderId); String payload = buildNewOrderMessage(order); ShopWebSocketHandler.sendToShop(order.getShopId(), payload); }4.3 单体部署能跑,多实例就要换方案
WebSocket 连接本身是有状态的,session 只存在于某一个后端进程内存里。如果服务部署成多实例,商家的长连接挂在实例 A,支付回调请求被负载均衡到实例 B,实例 B 里的SHOP_SESSIONS里根本没有这个商家,推送就丢了。
day09 的项目是单体应用,用ConcurrentHashMap管理 session 完全没问题。但如果以后想扩展,一定要把推送改成“发布订阅”模式:后端进程订阅 Redis 通道或消息队列,任何实例收到支付回调后,向通道发布一条“商家 5 有新订单”的消息,所有实例都会收到,再各自查本地有没有对应 session,有就推,没有就忽略。这个方案在真实项目里很常见,现在先了解一下,扩展时心里有数。
5. 状态机:订单状态扭转的并发安全
5.1 从0到5的状态流转表
订单状态是我在 day09 里认为最重要、也最容易想当然的部分。很多新人写状态更新就一句UPDATE order SET status = ? WHERE id = ?,完全不看当前状态是什么。这会导致什么?一个已完成的订单还能被“开始配送”,一个已取消的订单还能被“接单”,线上数据直接乱套。
整理成一个状态流转表会清楚很多:
| 当前状态 | 状态码 | 允许的操作 | 目标状态 |
|---|---|---|---|
| 待支付 | 0 | 用户支付回调 | 待接单(1) |
| 待支付 | 0 | 超时取消/用户主动取消 | 已取消(5) |
| 待接单 | 1 | 商家接单 | 已接单(2) |
| 已接单 | 2 | 开始配送 | 配送中(3) |
| 配送中 | 3 | 确认送达 | 已完成(4) |
这张表翻译成代码语言,就是在每个 UPDATE 语句里加一个“当前状态必须是 X”的条件。这样数据库层面就挡住了非法扭转,不依赖业务代码先查后改。
5.2 条件更新:用自己的状态做乐观锁
比如接单操作,不能这样写:
UPDATE `order` SET status = 2 WHERE id = #{id}应该写成:
UPDATE `order` SET status = 2 WHERE id = #{id} AND shop_id = #{shopId} AND status = 1这样如果订单已经被另一个操作员接过了,状态不再是 1,这条 SQL 影响行数就是 0,业务端通过影响行数来判断“接单失败”,直接返回“订单已被处理”。不需要加分布式锁,不需要锁表,这种利用状态字段做乐观锁的方式,单价状态下就是最简单可靠的方案。
同样的道理,支付回调、取消、开始配送、确认送达,都遵循这个套路:在 WHERE 条件里带上目标前置状态。我见过不少人只在代码里if (order.getStatus() == 1),然后执行 UPDATE,这在单线程下没问题,但两个管理员同时点接单时,两个请求都读到状态是 1,都去执行 UPDATE,最后一个会把订单覆盖成同一个状态,结果接口都返回成功,实际上多操作了一次。条件更新直接把并发安全下沉到数据库,比前台判断可靠得多。
5.3 商家操作必须带 shopId
商家接单、开始配送、确认送达,这些接口的入参不能只有 orderId。一个商家 100% 只能操作自己店铺的订单,所以 SQL 里必须加shop_id = #{shopId}条件。
这个设计和前面 WebSocket 推送的 key 是对应的:WebSocket 按 shopId 投递消息,商家端的操作也按 shopId 限定数据范围。否则哪一天商家端调用接口时传了别人的订单 id,后端照样把它状态改了,这就是越权操作。day09 不搞复杂权限框架,但查询和更新条件里带上 shopId,就是一种最朴素的越权防护。
6. 超时未支付自动取消:Redis 过期监听与定时任务兜底
6.1 key 过期事件的坑
用户下单后15分钟不支付,订单要自动取消。我们用的是“Redis 过期 key + 过期监听”方案,流程是:下单成功后在 Redis 写一个 15 分钟过期的 key,key 过期后 Redis 会发布一个过期事件,后端监听事件后去取消订单。
监听代码核心是:
container.addMessageListener(new OrderTimeoutListener(), new PatternTopic("__keyevent@0__:expired"));但用这个方案必须清楚几个坑。
第一个坑是 Redis 的过期事件默认可能没开启。需要在 Redis 配置里开启键空间通知,例如设置notify-keyspace-events Ex,不然后端永远收不到过期事件。
第二个坑是过期事件里只有 key,没有 value。所以 key 必须自带业务含义,比如order:timeout:1024,监听器拿到 key 后解析出订单 id。不能把订单 id 只放在 value 里,收到事件后连订单号都拿不到。
第三个坑是 key 过期事件本身有三个特性:Redis 会在 key 真正过期后的一段时间才发布事件;如果 Redis 在过期事件发布前重启,事件就丢了;如果有多个 Redis 实例,还要保证事件路由。所以过期监听只能作为“主要触发手段”,不能作为唯一保证。
6.2 定时任务兜底,别把命脉全交给 Redis
为了保证订单一定会被取消,我们加了一个定时任务作为兜底:每一分钟扫一次订单表,把“创建时间超过15分钟、仍然处于待支付状态”的订单找出来,逐个取消。
@Scheduled(fixedDelay = 60_000) public void autoCancelTimeoutOrders() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(15); List<Long> orderIds = orderMapper.selectPendingPayIdsBefore(deadline); for (Long orderId : orderIds) { orderTimeoutService.autoCancel(orderId); } }对应的取消 SQL 依然带上状态条件:
UPDATE `order` SET status = 5, cancel_time = NOW() WHERE id = #{orderId} AND status = 0 AND pay_status = 0定时任务扫表的缺点是全表扫描,订单量大了会很吃力,但 day09 阶段订单数据量不大,加上按create_time建索引,实际效果很好。如果以后订单量上来了,可以改成按分片时间扫、对逾期未支付订单加标记位等方式优化。
6.3 防误取消的最后一层屏障
不管是 Redis 过期监听还是定时任务,最怕的场景是这样的:用户下单 14 分 50 秒后发起支付,支付回调先走到,订单状态改成“待接单”,紧接着超时任务也跑到了,如果这个任务没有前置状态校验,就会把一个刚支付成功的订单取消掉。
所以自动取消操作里,必须同时校验status = 0(待支付)和pay_status = 0(未支付)。这两个条件缺一个都不能取消。影响行数为 0 时,说明订单状态已经变化,直接忽略,什么都不能做。这一层屏障看起来简单,但少了它,线上一定会出现“用户付了钱,订单却被取消”的严重投诉。
7. 联调全流程:模拟支付、双端验证和超时演练
7.1 建一个模拟支付入口
day09 阶段不会真接支付网关,为了把链路跑通,我们加了一个开发环境专用的模拟支付接口:
@PostMapping("/dev/mock-pay/{orderId}") public Result<Void> mockPay(@PathVariable Long orderId) { orderService.handlePaySuccess(orderId); return Result.success(); }这个接口的作用是模拟支付回调。调用后,订单从待支付变成待接单,业务持有的支付回调逻辑全部会执行:删 Redis key、更新状态、给商家推 WebSocket。要验证来单提醒有没有生效,直接调它就可以。
7.2 双端联调步骤
完整联调我们按下面这套流程走:
- 用户端登录,把几个商品加入购物车并提交订单,确认订单列表出现“待支付”记录。
- 调用模拟支付接口,确认订单状态变成“待接单”,用户端订单列表同步变化。
- 商家端登录另一个浏览器(注意不是同一个会话),确认能收到新订单的 WebSocket 提醒。
- 商家点击“接单”,确认状态从“待接单”变成“已接单”。
- 商家点击“开始配送”,确认状态从“已接单”变成“配送中”。
- 商家点击“确认送达”,确认状态从“配送中”变成“已完成”。
- 用户端刷新订单详情,确认整条状态记录和时间点都正确。
这一套走完,才算“主链路通关”。很多人的代码写完了,但商家端和用户端没有一起验,结果用户端显示的是“配送中”,商家端却还停在“待接单”,就是因为两个端操作的是不同订单或者状态没刷新。
7.3 超时场景怎么验
超时自动取消不能真等 15 分钟,有两种做法:第一种是临时把 Redis 的过期时间改成 30 秒,下单后等 30 秒看订单是否自动取消;第二种是直接把数据库里某条订单的create_time改成 20 分钟前,等待下一轮定时任务扫描。
验证的重点不是“订单有没有被取消”,而是“已支付的订单会不会被误杀”。所以至少要做两个场景:下单不支付,等超时时间到,确认订单变成已取消;先调用模拟支付把订单变到待接单,再手动把create_time改老,触发定时任务,确认订单没有被取消。后一个场景能测出 5.2 节和 6.3 节的设计是否真的生效。
8. day09 结束后还能再深挖的方向
8.1 退款链路
day09 只实现了“未支付订单取消”,但用户支付后仍然想取消订单、或者商家拒单怎么办?这时候就要设计退款链路。常见的做法是新增 refund 表,订单取消时生成退款单,退款单要记录原支付方式、退款金额、退款状态,最终和支付渠道对账。这里面的核心原则是:订单状态和退款状态要分开,退款不直接改订单支付状态,而是通过退款单的最终成功回调再同步更新。
8.2 骑手端与派单策略
外卖项目做到后面,还会有骑手端。谁去接单、系统怎么分配订单、骑手多久没接单要自动转派,又是一堆状态机。不过开始的思路可以很简单:已接单的订单,进入“待分配骑手”状态,骑手主动抢单,抢单操作和商家接单一样,用条件更新防并发。
8.3 订单幂等与重复支付
支付回调有可能因为网络抖动被调用两次,或者用户支付成功但回调延迟,导致超时取消已经先执行了。处理这类问题,核心就是所有状态更新都带上前置条件:支付回调的 UPDATE 里要求status = 0 AND pay_status = 0,影响行数为 0 就直接返回“重复处理”。幂等不用依赖外部框架,一张订单表加正确的 WHERE 条件就是最便宜的幂等方案。
day09 一天做下来,最大的收获不是“会写下单接口了”,而是开始用工程视角看待代码:事务要管到底,状态要拦住非法扭转,超时要设计兜底,通知要选择合适的推送时机。这一套东西放在任何交易类系统里都通用,也是苍穹外卖这个项目从“练习作业”向“小系统”转变的分水岭。