苍穹外卖这个项目,前九天很多同学都能顺畅地跟着敲,但到了day10、day11开始犯迷糊。不是代码变难了,而是你第一次要面对“一个功能从用户端一路打到商家端”的完整链路。day10和day11这两天,本质上只讲了一件事:把“购物车”变成“订单”,再把“订单”变成商家收银台里的一条待办。围绕这件事,会涉及下单接口的设计、支付状态的流转、定时任务兜底超时订单,以及WebSocket实时来单提醒。如果你也想搞清楚订单模块为什么这样设计,建议花点时间把这两天的逻辑自己推演一遍。这篇内容适合正在跟项目进度、或者准备在简历上写订单系统的同学参考,我会把关键设计和踩过的坑一起说清楚。
1. 这两天的内容到底在打通什么:从购物车到订单闭环
1.1 day10和day11在整个项目里的位置
苍穹外卖前面几天的安排,大致是员工端基础功能、分类菜品管理、Redis缓存优化,然后是用户端的小程序登录、地址簿、购物车。到day10之前,用户能逛、能加购物车,但业务链路是断的——用户买不了东西。day10和day11就是接上最后两段:用户提交订单并支付,商家端收到提醒并处理订单。学完之后你回头看整个项目,会发现它是一个完整的业务闭环:用户从浏览、加购、下单、支付,到商家接单、制作、配送、完成。
我自己当时最大的感受是:前面学的是“一个个孤立接口”,到了这两天,才开始学“接口之间怎么协作”。比如下单接口要动购物车、订单表、订单明细表、地址簿、商品数据;支付回调要动订单状态;定时任务要扫描订单表;WebSocket又把订单状态变化实时推给商家端。每个接口本身都不复杂,但它们之间的事务边界、状态一致性、并发问题,才是真正值钱的内容。
1.2 一次完整外卖流程涉及的角色和模块
我们模拟一次真实的下单过程,看看涉及哪些模块:
- 用户选择地址,勾选购物车商品,点击提交订单。
- 后端接收下单请求,校验地址簿和购物车,计算金额,写订单主表和订单明细表,清空购物车。
- 用户进行支付,支付结果回写到订单状态。
- 商家端收到新订单提醒(WebSocket推送),商家查看订单详情并接单。
- 如果用户下单后一直不支付,定时任务扫描超时订单并自动取消。
这条链路里,最容易被忽略的是“状态”的传递。订单不是一张静态的表,它有一整套生命周期:待支付、已支付/待接单、已接单、配送中、已完成、已取消。day10和day11的所有代码,本质上都是在维护这个状态机,并保证状态迁移的正确性。
1.3 一个核心矛盾:用户端和商家端不是同一套接口
做这个项目时有一个容易混淆的点:用户端的订单接口和商家端的订单接口虽然都叫“订单”,但服务对象完全不同。用户端关心的是我的订单到哪一步了、我要取消、我要再来一单;商家端关心的是有没有新订单、怎么接单、怎么标记完成。day10偏向用户端的下单和支付,day11偏向商家端的提醒和处理。分开理解会轻松很多,如果混在一起看,就会觉得订单功能怎么这么多接口。
2. 下单链路的核心设计:商品快照、事务边界与订单号
2.1 下单接口的输入与参数校验
先看下单接口接收什么。前端提交时一般传这些字段:地址簿ID、付款方式、备注,还有可能带上购物车ID列表。后端拿到请求后,第一件事不是写订单,而是做校验。苍穹外卖里的常规做法是查地址簿,如果地址不存在直接抛异常;再查购物车,如果购物车为空,说明用户可能重复提交了订单,也要拦下来。
这里我建议你额外留一个心眼:前端传过来的金额,后端一定要重新计算一遍。教学项目里很多地方直接信任前端传的amount,但真实业务里金额必须以后端计算为准。购物车里每一项都有单价和数量,遍历累加就能得到真实金额。如果前端传的金额和后端算出来的不一致,比较稳妥的做法是以后端计算为准,或者直接提示用户重新拉取购物车信息。这个习惯养成了,以后做任何涉及钱的接口都受益。
还有一点容易被忽略:地址簿数据要“拷贝”到订单表,而不是只存一个地址ID。为什么?因为用户地址后续可能修改或删除,而订单作为历史数据,收货人姓名、手机号、详细地址必须保持下单那一刻的快照。这个思想叫“数据快照”,后面商品名称和价格也同理。
2.2 订单主表与订单明细表的设计思路
苍穹外卖里订单相关有两张核心表:订单表和订单明细表。订单表存的是汇总信息:订单号、总金额、下单时间、支付方式、收货地址快照、订单状态等。订单明细表存的才是具体商品:菜品名称、图片、单价、份数、小计。
为什么非要拆成两张表?因为一个订单可能包含多个商品,如果都塞在一张表里,要么冗余大量订单公共信息,要么设计成逗号分隔的字符串字段。拆分之后,订单表和明细表是一对多的关系,查询一个订单的完整信息时,先查订单主表,再按订单ID查明细列表。这个设计也方便后续做数据统计,比如算某个菜品的销量,直接查明细表按菜品ID分组求和即可。
写入顺序也有讲究。正确的做法是:先插入订单主表拿到订单ID,再循环插入订单明细表。注意明细表里要回填订单ID。有同学在这里踩过坑——先插入明细再插入主表,结果明细表里的订单ID一直为空,页面上订单详情就是空白。
商品明细同样要用快照思想。菜品表里的名称、价格、图片都是会变的,但用户下单那一刻看到的菜名和价格必须原样保留。所以订单明细表里不能只存一个菜品ID,要把名称、图片、单价、份数都冗余进去。很多同学不理解为什么表里会有这些“重复”字段,做过后台管理系统就会明白:等你回头查历史订单时,如果不做快照,看到的是改过之后的菜名和价格,客诉都没法处理。
2.3 事务边界和订单号的生成
下单接口必须是一个整体事务。手动校验地址簿、查询购物车、计算金额、插入订单主表、批量插入明细表、清空购物车,这六步要么全部成功,要么全部回滚。比如订单主表插入成功了,但明细表插入过程中抛异常,如果不回滚,就会出现一个没有任何商品的“空订单”,用户端和管理端都会看到脏数据。
苍穹外卖里一般用Spring的@Transactional注解标注在下单Service方法上。有一个细节值得注意:事务粒度不要开得太大。如果事务里还做了大量的远程调用、WebSocket推送、外部接口请求,事务会长时间占用数据库连接。下单这个场景事务范围就在本地数据库操作上,推送消息这些事放到事务提交之后再做。这个经验我放到后面“容易翻车”部分详细说。
再聊聊订单号。订单表里一般会有一个number字段,这个字段不是主键ID,而是展示给用户的订单编号。教学项目里常见的是“时间戳+随机数”或者“时间戳+用户ID尾巴”,然后转成字符串。为什么不用数据库自增ID当订单号?因为自增ID会暴露业务量,用户也能猜到别人的订单号,而且以后再对接第三方支付时,外部系统回传的商户订单号也需要一个业务上唯一的字符串。订单号不要求全局绝对唯一,但并发情况下不能重复,时间戳加用户维度加随机数基本够用。
3. 支付模块的务实做法:模拟支付、回调与状态流转
3.1 为什么项目里普遍用模拟支付
真实接入支付渠道需要商户号、证书、回调域名、应用ID等一系列资质,个人学习者很难快速搞定,而且涉及资金流水,出问题风险不小。所以苍穹外卖这类教学项目,在day10和day11里通常用“模拟支付”来代替真实支付链路。所谓模拟支付,就是提供一个接口,前端点击支付按钮后,直接调用这个接口把订单状态从未支付改成已支付,不经过任何第三方支付平台。
有同学会问:那这样学到的支付逻辑还有用吗?有用,而且核心部分完全一致。真实支付链路里,最难的不是调起支付,而是处理支付结果回调和订单状态一致性。模拟支付把“支付结果必然到达”这个环节简化了,但订单状态的更新逻辑、幂等保护、归属校验这些思想,和真实业务一模一样。
我在实操中的体会是:模拟支付接口反而能让你更专注地理解“回调”的本质。真实世界里,支付平台会异步通知你的服务器“某笔订单支付成功”,你的服务器需要根据这个通知把订单状态改掉。模拟支付只是把这个异步通知变成了同步调用罢了。
3.2 订单状态机的设计
订单表里通常有一个status字段,用数字表示不同状态。不同项目编号略有差异,但状态设计思路大体一致:
| 状态值 | 含义 | 触发时机 |
|---|---|---|
| 1 | 待支付 | 用户提交订单后 |
| 2 | 已支付 / 待接单 | 支付成功 |
| 3 | 已接单 | 商家接单 |
| 4 | 配送中 / 制作中 | 商家操作 |
| 5 | 已完成 | 订单完结 |
| 6 | 已取消 | 用户取消或超时取消 |
| 7 | 退款 | 售后场景 |
为什么不用一个布尔字段比如is_paid来表示?因为订单生命周期不只是“未支付/已支付”两个状态。一个已支付的订单可能被商家接单,也可能被用户取消后需要退款;一个超时未支付的订单和用户主动取消的订单虽然都叫“已取消”,但原因不同,后续处理也可能不同。状态机设计的好处是:任何时刻订单都有且只有一个明确状态,业务流程每一步都对应一次合法的状态迁移。
写代码时一定要有“状态迁移合法性”的意识。比如支付接口里,只有状态为1的订单才能变成状态2;如果订单已经是6(已取消),就不能再支付成功了。如果忽略这层判断,用户下单后去支付,同时系统定时任务扫描该订单超时并取消,两个操作并发执行,就可能出现“订单已取消但支付也成功”的矛盾数据。避免这个问题的办法很简单:更新时带上状态条件,用类似UPDATE orders SET status = 2 WHERE id = ? AND status = 1这样的SQL,影响行数为0就说明状态已经被别人改了。
3.3 模拟支付接口的幂等和归属校验
看下模拟支付接口的常规实现。用户端点击支付,传入订单ID,后端先查订单,判断状态是否为待支付,然后改为已支付并记录支付时间。这个接口有两个必须做的保护:
第一个是幂等性:支付通知可能重复下发,用户也可能手快点了两次支付按钮。如果订单已经是已支付状态,接口再执行一次,不能把状态改回去,也不能报一个让用户困惑的异常。正确做法是发现状态不是待支付,直接返回“该订单已被支付”之类的提示,不破坏已有状态。
第二个是归属校验:支付接口一定要确认这个订单属于当前登录用户。教学项目里如果只传订单ID,有安全意识的同学会发现,随便改一个订单ID就能操作别人的订单。解决方法是后端从当前登录用户上下文里拿到用户ID,然后查询订单时同时带上用户ID条件,或者查出来之后比对订单的userId字段。这个习惯在真实项目中是底线要求。
再说一个细节:支付成功之后,订单表里除了改status,还要记录支付时间字段。这个时间后面很多地方要用来判断“用户超时未支付”。如果只改状态不记录pay_time,后面定时任务扫超时订单时容易错乱。苍穹外卖里订单表会预留pay_time、cancel_time、complete_time这类字段,每发生一次关键状态变更,就要对应更新时间字段。
4. 定时任务兜底:超时未支付订单的自动取消
4.1 为什么需要定时任务来处理超时订单
用户提交订单之后,如果没有在规定时间内完成支付,这笔订单会一直占着库存和运力。外卖场景通常设置15分钟,超时未支付自动取消。这个逻辑不可能靠用户主动触发,也不能靠用户端定时请求,因为用户可能直接关掉小程序走了。可靠的做法是服务端通过定时任务不断扫描订单表,把超时的订单挑出来,统一改成已取消状态。
Spring框架提供了现成的定时任务方案:在启动类上加@EnableScheduling注解,然后在任务类的方法上加@Scheduled注解,配合cron表达式控制执行频率。苍穹外卖day10里面一般会做一个OrderTask类,里面放一个方法专门处理超时订单,另一个方法处理“商家一直没接单的订单”,逻辑类似,都是根据状态和时间的组合条件去扫描并推进状态。
4.2 cron表达式和扫描SQL怎么写
cron表达式看起来吓人,拆开就好理解了。常见写法是0/30 * * * * ?,意思是从第0秒开始,每30秒执行一次。从左到右分别是秒、分、时、日、月、星期。星号表示任意值,问号用于星期和日字段的互斥占位。写定时任务时,执行频率要结合业务容忍度来定。超时订单取消晚几十秒对用户体验影响不大,每30秒扫一次足够;如果业务上要求秒级处理,才需要缩短到秒级。
扫描SQL的核心是查询某个时间点之前、且状态为待支付的订单:
SELECT * FROM orders WHERE status = 1 AND order_time <= DATE_SUB(NOW(), INTERVAL 15 MINUTE);这里的时间字段是关键。如果你用了LocalDateTime存下单时间,要注意数据库时间和应用服务器时间的时区是否一致。很多同学第一次跑定时任务时发现订单被立刻取消,十有八九是时区问题——数据库存的是UTC时间,Java端读出后转了本地时间,两边差了8小时,扫描条件就和直觉对不上了。
另外要提一点性能意识:如果订单表数据量很大,每次全表扫描status=1的订单会拖垮数据库。实际项目里一般会给status和order_time建联合索引,并且定时任务分批处理,比如每次只取100条,处理完再取下一批。教学项目数据量小,体现不出差别,但把这条优化思路写进代码注释里,面试时就能讲出亮点。
4.3 定时任务执行要注意的隐性坑
第一个坑是集群环境下的重复执行。如果项目部署了多个实例,Spring的@Scheduled默认会在每个实例上各跑一遍,同一笔超时订单可能被多个实例同时取消。处理方式有几种:最简单的是只在某个固定节点部署定时任务;严谨一点可以引入分布式锁,让同一时间只有一个实例执行任务;再讲究一点,数据库层面对订单ID加行锁配合状态条件更新,也能保证幂等。教学项目单机部署无所谓,但你要知道这个问题存在。
第二个坑是任务执行时间和业务高峰的冲突。比如商家处理订单的任务如果在整点跑,刚好多实例同时扫描大表,数据库压力瞬时上升。建议把cron表达式错峰设置,比如每秒执行的任务避开15分、30分这些整点,减少和报表任务撞车。
第三个坑是日志。定时任务出问题通常不是立刻暴露的,而是很久之后用户投诉“我的订单怎么被取消了”。所以任务里一定要打印扫描到的订单数量和处理的订单ID,方便事后追溯。我习惯在任务开始和结束时各打一条日志:begin scan、end scan count=xx。朴素但好用。
5. WebSocket来单提醒:从轮询到实时推送的升级
5.1 为什么商家端要用WebSocket而不是轮询
现在的需求是:用户支付成功后,商家收银台要立刻收到新订单提醒。最容易想到的做法是商家端页面每隔几秒调一次接口,查有没有新订单,这叫轮询。轮询的问题很明显:如果间隔短,服务器被无效请求打满;如果间隔长,提醒不及时。而且用户支付之后,商家可能正在忙,新订单要能主动冒出来提示音和弹窗。
WebSocket解决的是“服务端主动推送”的问题。它是一条长连接,客户端和服务端建立连接后,服务端可以在任意时刻主动往客户端发数据。这和HTTP的“一问一答”模式完全不同。商家收银台页面打开时建立一个WebSocket连接,服务端收到支付成功的消息后,往所有商家端连接推送一条“来单了”的通知,前端收到后播放提示音并刷新订单列表。
苍穹外卖里WebSocket主要用在后端管理端的来单提醒。后续如果有需求,比如订单状态变化实时通知用户小程序,同样是这套方案。学会了这一处,其他地方就是复制思路的问题。
5.2 服务端WebSocket的实现要点
Spring Boot集成WebSocket比较简单,核心是自己写一个WebSocket服务端类。苍穹外卖里一般基于Java自带的javax.websocket实现,关键点是维护一个会话集合:
@Component @ServerEndpoint("/ws/{sid}") @Slf4j public class WebSocketServer { // 会话集合,key可以用用户ID或客户端标识 private static final Map<Long, Session> SESSION_MAP = new ConcurrentHashMap<>(); @OnOpen public void onOpen(@PathParam("sid") Long sid, Session session) { SESSION_MAP.put(sid, session); log.info("客户端连接,sid={}", sid); } @OnClose public void onClose(@PathParam("sid") Long sid) { SESSION_MAP.remove(sid); log.info("客户端断开,sid={}", sid); } @OnError public void onError(Session session, Throwable error) { log.error("WebSocket错误", error); } public static void sendToAll(String message) { for (Session session : SESSION_MAP.values()) { try { if (session.isOpen()) { session.getBasicRemote().sendText(message); } } catch (IOException e) { log.error("推送失败", e); } } } }这里有几个细节要注意。SESSION_MAP必须用ConcurrentHashMap,因为WebSocket的打开和关闭事件可能由不同线程触发;推送前要判断session.isOpen(),否则向已关闭的连接写数据会抛异常;onClose里要移除会话,不然连接断开后Map里残留一堆失效Session,内存和推送效率都会受影响。
5.3 触发时机和前端接收
来单提醒的触发时机是什么?严格来说,应该是支付成功回调里,订单状态从待支付变为已支付之后,往商家端推送一条消息。代码上就是在支付Service更新完订单状态后,调用WebSocketServer.sendToAll()。推送的内容可以是简单的JSON字符串,比如{"type": 1, "content": "您有新的订单"},前端拿到后解析,再根据type决定是弹窗、播放提示音还是刷新列表。
前端页面在收银台打开时,创建一个WebSocket对象:
let ws = new WebSocket(`ws://localhost:8080/ws/1`); ws.onmessage = function (event) { let data = JSON.parse(event.data); // 根据data.type做对应处理 if (data.type === 1) { // 刷新订单列表并播放提示音 } };小程序的写法类似,换一个API接口就行。联调时最容易出的问题是路径配置:后端接口地址写成了http,前端连不上;或者端口写错,WebSocket握手直接失败。建议先启动后端,用页面访问ws://localhost:8080/ws/1,看到控制台打出连接日志,再继续往下联调。
这里还要提醒一个业务边界:WebSocket它只管实时推送通知,不保证推送的数据就是用户要看到的最终数据。前端收到提醒后,正确的做法是再调一次订单列表接口刷新数据,而不是相信推送里带的订单详情。推送只是一个“触发信号”,数据一致性还得靠查询接口保证。
6. 我实测下来最容易翻车的几个点
6.1 事务还没提交就推送消息
先说一个我实际踩过的坑。用户支付成功后,我一开始是在Service方法内部直接调用WebSocket推送,而这个Service方法是带@Transactional的。表面看没毛病,但实际上推送消息发出去了,事务还没有提交——商家端收到提醒后立刻去查订单列表,查到的还是“待支付”状态,因为数据库事务还没结束,别人读不到未提交的修改。
解决思路有两种:一是把推送动作放到事务提交之后,Spring里可以用事务同步的afterCommit回调,或者@TransactionalEventListener监听事务提交事件;二是更简单粗暴的方式,把推送从事务方法里拆出来,由外层调用方先完成事务,再调用推送。教学项目里直接拆出来做反而最易懂,等你理解之后再改成监听事件的方式。
6.2 状态判断写反导致已取消订单被支付成功
模拟支付接口里,判断订单状态的地方很容易写反。比如:
if (order.getStatus() != 1) { // 这里应该是终止支付 }如果写成== 1才终止,那所有已支付和已取消的订单都能再支付一次,状态就会被改乱。这个问题的排查往往要等数据乱了才发现。我的建议是给状态定义加枚举或者常量,不要让魔法数字散落在代码各处。比如定义常数STATUS_PENDING_PAYMENT = 1,判断时写if (order.getStatus() != STATUS_PENDING_PAYMENT) return error,可读性高,也不容易写反。
6.3 时区问题导致超时订单被秒取消
前面提过时区问题,这里展开讲一下。有一次我本地跑定时任务,刚下的订单一分钟后就被自动取消了。排查日志发现SQL条件查出来的订单时间比预期早了8小时,原因是数据库会话的时区设置和Java进程的默认时区不一致。后来在数据库连接参数里显式指定了serverTimezone,并统一用同一个时间格式序列化LocalDateTime,问题才稳定。
这个坑在本地开发很容易忽略,因为本地测试数据少,偶尔跑一次没感觉。但一旦上了服务器,数据库和应用的时区如果不一致,订单取消逻辑就会莫名失控。建议从第一天做这个项目起,就在项目配置里把时区问题规范化处理。
6.4 清空购物车失败导致用户重复下单
下单成功后要清空购物车。如果这一步没执行成功,用户看到购物车还是满的,就会再下一单,产生重复订单。清空购物车逻辑本身很简单:按用户ID删除购物车记录。但要注意它必须在下单主事务里,如果前面的订单写入成功、清空购物车失败,整体就要回滚,否则购物车里的商品又变成“待下单”状态。我见过一个同学把清空购物车单独做了一个事务,导致下单成功了购物车还在,连续点几次就建了三个一模一样的订单。
6.5 WebSocket连接失效但不自知
WebSocket长连接最怕静默断连。用户可能关了电脑、网络切换、服务器重启,连接断开后服务端不一定立刻感知。实际项目里需要做心跳机制:客户端每隔一段时间发一个ping,服务端回pong;或者服务端定期检查连接的空闲状态,超过阈值就主动关闭。苍穹外卖项目里一般不强求心跳,但你要知道这个机制的用途,面试时聊到WebSocket,能主动说出“生产环境要加心跳和断线重连”,就是一个加分项。
整套跑下来,我个人的体会是:day10和day11的代码量不算大,核心价值在于把前面学的零散知识串联成一个完整业务闭环。如果让我重新做一遍,我会先亲手画一遍订单状态流转图,把每个状态变更的触发条件和涉及的表字段列出来,再动笔写代码。这样做的好处是,遇到并发和边界问题时,你能一眼看出哪里缺少保护。建议你也试试这个方法,画完之后你会发现,后面所有订单相关的功能都是在给这张图补细节。