把苍穹外卖这个项目做到 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单明细表结构大概是这样的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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;主表和明细表为什么要分开如果不分一次下单买五个菜品就要在主表里存五行收货人、订单号、金额全部重复一遍后面改状态还得连带着改好几行字段一多很容易不一致。拆开之后主表一行对应一次订单明细表多行对应订单里的菜品订单详情页要展示时直接按 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. 查询购物车 ListCart 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 ConcurrentHashMapLong, 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 分钟过期的 keykey 过期后 Redis 会发布一个过期事件后端监听事件后去取消订单。监听代码核心是container.addMessageListener(new OrderTimeoutListener(), new PatternTopic(__keyevent0__: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); ListLong 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 ResultVoid 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 一天做下来最大的收获不是“会写下单接口了”而是开始用工程视角看待代码事务要管到底状态要拦住非法扭转超时要设计兜底通知要选择合适的推送时机。这一套东西放在任何交易类系统里都通用也是苍穹外卖这个项目从“练习作业”向“小系统”转变的分水岭。