首页 / 资讯中心 / 文章详情

SpringCloud外卖系统五端源码拆解:订单状态机与抢单派单实战

SpringCloud外卖系统五端源码拆解:订单状态机与抢单派单实战 ★ FEATURED ARTICLE
简介面向学习O2O外卖平台后端与数据库设计的中级开发者这份压缩包提供饿了么风格系统的数据库脚本可服务于SpringCloud微服务项目的建库建表、业务实体梳理以及课程设计或毕业设计参考。包内共3个SQL文件压缩包约65KB内容围绕订单、商城与客户端订单等数据表展开可用于还原客户端、商家端、配送端、订单端和总后台等模块的核心数据结构。已有486人学习下载对其理解外卖业务表关系具有一定参考价值。读者可从中观察用户、商家、菜品、订单等实体的字段设计与表间关联结合关系型数据库的规范化思路辅助完成微服务项目的数据库落地、接口数据建模和事务一致性验证也可对照订单流转、商品展示、用户下单等场景查漏补缺为后续后端开发与表结构优化提供可复用的参考。1. 一份五端齐全的外卖后端源码能直接用来做什么有个做前端的哥们拿到这份 O2O 外卖系统的后端代码第一反应是劝退客户端、商家端、配送端、订单端、总后台五个端加一个数据库看着像一整个团队磨了半年的活。真拆开看主干只有一条——用户在客户端下单订单服务落库并把消息推给商家端商家接单后由配送端骑手抢单总后台负责审核商家、调度和兜底。整套后端基于 SpringCloud 微服务搭建注册中心、网关、服务间调用、缓存、消息队列都是外卖场景里最常见的组合。它适合三类人想跑通一个完整业务闭环的初中级后端、要拿现成骨架做课程设计或练手的开发者、以及想看看真实订单状态机怎么写的熟手。功能不花哨但从加购到骑手送达的链路是齐的这一点比很多只有增删改查的“外卖系统”值钱。2. 架构与服务拆分SpringCloud 怎么把这五个端装进一个工程先看工程骨架。这类四端加总后台的项目通常是 Maven 多模块结构父 pom 里锁定 Spring Boot 和 Spring Cloud 的版本下面按业务竖切再抽两个公共模块出来。目录长相大致是这样o2o-parent ├── o2o-common # 统一返回、异常、常量、工具、JWT ├── o2o-api # Feign 接口 DTO服务之间只依赖它 ├── o2o-gateway # 网关唯一对外入口 ├── o2o-user # 客户端用户、地址、登录 ├── o2o-merchant # 商家端店铺、菜品、库存、接单 ├── o2o-order # 订单端下单、支付回调、状态机 ├── o2o-delivery # 配送端骑手、派单、轨迹 └── o2o-admin # 总后台审核、结算、看板o2o-api这个模块是很多人容易忽略的一层。把 Feign 接口和 DTO 单独抽出来服务之间只依赖接口模块不依赖对方的实现包后面拆库、改协议的时候不至于全量重编译。我见过不少人图省事让订单服务直接引入商家服务的实体类最后两张表字段一改连锁反应能折腾一下午。2.1 服务边界与端口规划谁该独立成服务外卖这个业务场景天然有三条会互相抢资源的高频路径下单、接单、派单。所以订单、商家、配送必须拆开否则大促时商家端查菜品把订单接口拖死一个线程池全堵住。反过来用户和地址没必要单独拆合在o2o-user里就够拆太细只会让联调多两跳网络往返。我按这类项目常见的规划整理了一份对照端口和职责以你手上application.yml的实际值为准这里给的是排查参考模块常见端口职责是否对外暴露o2o-gateway8080路由、鉴权、限流、跨域是唯一入口o2o-user8081登录注册、地址、用户信息否o2o-merchant8082店铺、菜品、上下架、库存否o2o-order8083下单、支付回调、订单状态机否o2o-delivery8084骑手、派单、配送状态否o2o-admin8085商家审核、对账、数据看板否标题里的“订单端”容易让人误解成前端应用实际上它对应的是订单服务加上一块运营用的订单管理界面。真正需要单独开 App 的是三端用户端、商家端、骑手端总后台跑在浏览器里。理清这一点读代码的时候就不会跑去前端目录里找订单状态机了。2.2 技术选型清单选了谁、为什么不用另一个微服务那套组件在这个项目里不是炫技是被业务逼出来的。跨服务调用用 OpenFeign是因为下单要同时问用户服务“这个地址是不是他的”、问商家服务“这个菜还在不在架上”HTTP 语义清晰、排查方便换成 RPC 虽然快但出问题时抓包都费劲。注册中心和配置中心一般用 Nacos 一体解决比 Eureka 加 Config 两套省事。网关用 Spring Cloud Gateway靠 JWT 在网关层就把身份解析出来再把用户 ID 塞进请求头往下游传下游服务就不必每个都写一遍鉴权。几条容易被问到的取舍缓存用 Redis承担三件事——菜品列表、骑手位置、以及下单幂等键。骑手位置用 Hash 存按区域分桶别把全国骑手塞一个 key。消息队列用在“下单成功通知商家”“派单广播”这类场景。订单超时取消如果不想引入延迟消息用定时任务扫表也能顶量小的时候扫表反而更省心。分布式事务不要一上来就上 Seata。下单这条链路里扣库存和建订单可以靠“本地事务 消息最终一致 对账补齐”扛过去两阶段提交的锁等待在高峰期是灾难。文件存储走对象存储或 MinIO菜品图、店铺招牌图不进数据库只存 URL。提示如果 pom 里同时出现了 Spring Cloud 的原生组件和 Spring Cloud Alibaba 组件务必核对版本对应关系Boot、Cloud、Alibaba 三者版本错配是启动失败的头号原因报错还往往指向一个完全无关的类。2.3 数据库设计三张表撑起整条订单链路数据库这块外卖系统的坑集中在“订单”上。订单主表存聚合信息明细表存菜品快照配送单表存骑手侧数据。菜品快照这一条特别重要——用户三个月后翻历史订单看到的价格和菜名必须是下单那一刻的不能跟着商家改价一起变。很多人偷懒在明细表只存 sku_id结果商家一改价历史订单全乱。CREATE TABLE t_order ( id BIGINT NOT NULL COMMENT 订单号雪花ID, user_id BIGINT NOT NULL COMMENT 下单用户, merchant_id BIGINT NOT NULL COMMENT 商家ID, address_snap VARCHAR(512) NOT NULL COMMENT 收货地址快照JSON, goods_amount DECIMAL(10,2) NOT NULL COMMENT 商品金额, pack_fee DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 打包费, delivery_fee DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 配送费, pay_amount DECIMAL(10,2) NOT NULL COMMENT 实付金额, status TINYINT NOT NULL COMMENT 10待支付 20待接单 30制作中 40待取餐 50配送中 60已完成 70已取消, expect_time DATETIME NULL COMMENT 预约送达时间即时单为NULL, create_time DATETIME NOT NULL, cancel_time DATETIME NULL, PRIMARY KEY (id), KEY idx_user_ctime (user_id, create_time), KEY idx_merchant_status (merchant_id, status), KEY idx_status_ctime (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;索引不是随手加的。idx_user_ctime服务于“我的订单”列表按时间倒序翻页idx_merchant_status服务于商家端“待接单”红点数量统计idx_status_ctime给超时取消的定时任务用——扫表时只走这一个索引别写成WHERE status IN (10,20) AND ...之后又在别的字段上做函数运算索引直接失效。明细表和配送单表的字段就不全列了只说两个关键点明细表要有order_id索引且存sku_name、unit_price快照配送单表要有order_id唯一索引防重复派单并给rider_id status建联合索引查骑手在途单量。3. 从零跑起来环境、配置、启动顺序三步走拿到源码最怕的是东启一个西启一个最后满屏红色不知道先修哪个。我的习惯是先把中间件拉起来再按依赖顺序启动每启一个就用接口验一次别一次性全点运行。3.1 中间件与环境准备需要的东西不多JDK、Maven、MySQL、Redis、Nacos用到消息队列的还要再加一个。本机装了原生服务的直接用图省事的用容器跑# MySQL注意字符集和时区外卖系统里中文菜名和下单时间都很敏感 docker run -d --name o2o-mysql -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDroot123 \ -e TZAsia/Shanghai \ mysql:8.0 --character-set-serverutf8mb4 --collation-serverutf8mb4_general_ci # Redis菜品缓存、幂等键、骑手位置都靠它 docker run -d --name o2o-redis -p 6379:6379 redis:6 # Nacos单机模式足够本地开发 docker run -d --name o2o-nacos -p 8848:8848 -p 9848:9848 \ -e MODEstandalone nacos/nacos-server起完之后先别急着导库。SQL 脚本一般在项目的sql/或doc/目录下如果是一个大文件先看它有没有CREATE DATABASE语句——没有的话你得自己建库而且往往不止一个库。分库的项目每个服务一个 schema库名和application.yml里的 datasource 必须一一对应这个对不上是新手最常见的卡点。导入时用命令行比图形界面稳能直接看到报错停在哪一行mysql -h127.0.0.1 -uroot -proot123 -e CREATE DATABASE IF NOT EXISTS o2o_order DEFAULT CHARSET utf8mb4; mysql -h127.0.0.1 -uroot -proot123 o2o_order sql/o2o_order.sql如果脚本报Unknown collation: utf8mb4_0900_ai_ci说明脚本是在 MySQL 8 上导出的你本机是 5.7全局替换成utf8mb4_general_ci即可。这种错一眼就能定位怕的是脚本静默跳过几行后面跑起来报“表不存在”。3.2 配置文件改哪几处每个服务的application.yml要改的其实就四类端口、数据源、Redis、Nacos 地址。以订单服务为例server: port: 8083 spring: application: name: order-service # 必须与网关路由里的服务名完全一致大小写敏感 datasource: url: jdbc:mysql://127.0.0.1:3306/o2o_order?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 database: 0 cloud: nacos: discovery: server-addr: 127.0.0.1:8848 config: server-addr: 127.0.0.1:8848 file-extension: yaml几个参数值得单独说。serverTimezoneAsia/Shanghai不加的话下单时间和数据库时间会差 8 小时配送超时判断直接错乱这个坑很隐蔽日志里看不出来只有对着表里的create_time才发觉得到。allowPublicKeyRetrievaltrue是 MySQL 8 用新密码插件时才需要不加会报公钥检索失败。spring.application.name是服务注册到 Nacos 的名字网关的lb://order-service就靠它对上名字写成order_service或者驼峰网关直接 503。如果项目接了 Nacos 配置中心本地改application.yml可能被远端的配置覆盖。判断方法很简单启动日志里搜一下加载了哪些 dataId如果发现远端有同名配置要么在 Nacos 控制台改要么把spring.cloud.nacos.config.import-check.enabled相关的强校验关掉先跑通。3.3 启动顺序与一条下单链路的验证顺序是死的Nacos → 各个业务服务 → 网关。网关最后启因为它启动时要去拉服务列表业务服务没起来它也不会报错只是路由转发全部 503容易误判成网关坏了。业务服务之间也有先后。订单服务启动时会通过 Feign 初始化一些客户端虽然 OpenFeign 是懒加载但如果你在启动类里做了什么预热依赖方没起就会抛异常。稳妥的做法是按 user → merchant → order → delivery → admin → gateway 的顺序点。全部起来后别急着开前端先用接口工具把主链路走一遍# 1. 登录拿 token验证码在开发环境一般是固定值或直接关掉 curl -X POST http://127.0.0.1:8080/api/user/login \ -H Content-Type: application/json \ -d {phone:13800000000,code:123456} # 2. 带上 token 下单idempotentKey 由客户端进入结算页时生成 curl -X POST http://127.0.0.1:8080/api/order/create \ -H Content-Type: application/json \ -H Authorization: Bearer eyJhbGciOi... \ -d {addressId:1,merchantId:10,idempotentKey:a1b2c3,remark:不要香菜}第一行返回的data.token拿出来第二行替换掉示例里的那串。下单成功会返回订单号这时候去数据库查t_order状态应该是 10 待支付再去 Nacos 控制台看订单服务的实例列表是不是健康状态。如果下单返回 401是网关没放行这个路径返回 500 且日志里是NullPointerException八成是购物车为空或者菜品已下架没做空判断——这两条后面还会细说。4. 核心链路拆解一笔订单从加购到骑手接单这一段是整份源码里最值得读的部分。跳过登录、改头像这些边角只看下单和派单你会发现外卖系统的复杂度几乎全在状态流转和并发控制上。4.1 下单入口金额一律服务端重算客户端的结算页会传一堆东西过来但真正可信的只有addressId、merchantId和一份购物车勾选状态。价格、配送费、优惠全部要在服务端算一遍。这不是谨慎是必须——前端传的payAmount只要被人改一次损失就是真金白银。Service RequiredArgsConstructor public class OrderServiceImpl implements OrderService { private final CartMapper cartMapper; private final SkuMapper skuMapper; private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final StringRedisTemplate redisTemplate; Override Transactional(rollbackFor Exception.class) public Long createOrder(Long userId, Long addressId, String idempotentKey) { // 幂等同一结算批次只允许落一单key 由客户端进结算页时生成 Boolean first redisTemplate.opsForValue().setIfAbsent( order:idem: userId : idempotentKey, 1, 10, TimeUnit.MINUTES); if (Boolean.FALSE.equals(first)) { throw new BizException(订单提交中请勿重复点击); } ListCart carts cartMapper.selectCheckedByUser(userId); if (carts.isEmpty()) { throw new BizException(购物车里还没有选中的商品); } // 金额从数据库的 sku 现价算前端传的金额一概不接收 BigDecimal goodsAmount BigDecimal.ZERO; ListOrderItem items new ArrayList(carts.size()); for (Cart cart : carts) { Sku sku skuMapper.selectById(cart.getSkuId()); if (sku null || !Integer.valueOf(1).equals(sku.getStatus())) { throw new BizException(sku null ? 菜品不存在 : sku.getName() 已下架); } BigDecimal line sku.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity())); goodsAmount goodsAmount.add(line); // 快照菜名和单价写死在这一行后面商家改价不影响历史订单 items.add(new OrderItem(sku.getId(), sku.getName(), sku.getPrice(), cart.getQuantity())); } // 配送费按距离阶梯这里简化成商家配置的固定值实际项目一般走距离矩阵 BigDecimal deliveryFee calcDeliveryFee(addressId, carts.get(0).getMerchantId()); BigDecimal packFee calcPackFee(carts); BigDecimal payAmount goodsAmount.add(deliveryFee).add(packFee) .setScale(2, RoundingMode.HALF_UP); Order order new Order(); order.setId(idGenerator.nextId()); // 雪花 ID不要用自增 order.setUserId(userId); order.setMerchantId(carts.get(0).getMerchantId()); order.setGoodsAmount(goodsAmount); order.setDeliveryFee(deliveryFee); order.setPackFee(packFee); order.setPayAmount(payAmount); order.setStatus(OrderStatus.WAIT_PAY.getCode()); order.setAddressSnap(addressService.snapshot(addressId, userId)); orderMapper.insert(order); items.forEach(i - i.setOrderId(order.getId())); orderItemMapper.insertBatch(items); // 清掉已结算的购物车行避免下一单重复带出 cartMapper.deleteBatch(carts.stream().map(Cart::getId).collect(Collectors.toList())); return order.getId(); } }这段代码有几个刻意的设计。幂等键用 Redis 的setIfAbsent而不是先查再写后者在并发下必然穿透过期时间给 10 分钟够覆盖用户手抖连点的窗口又不至于占内存。金额全程用BigDecimalsetScale(2, HALF_UP)放在最后一步统一处理中间每一步都四舍五入会累积误差对账时差几分钱能查到怀疑人生。订单 ID 用雪花算法生成因为分库之后自增 ID 会撞而且订单号本身不该暴露“今天下了多少单”这种信息。Transactional(rollbackFor Exception.class)里的rollbackFor别省。默认只回滚RuntimeExceptionFeign 调用抛的checked异常不会触发回滚订单落了、明细没落数据就瘸了。4.2 订单状态机把流转规则收进一个枚举订单状态是这类系统最容易写崩的地方。到处都是if (status 20) {...}改一个需求要全文搜索。合格的做法是把“什么状态能到什么状态”集中成一张表。public enum OrderStatus { WAIT_PAY(10, 待支付), WAIT_ACCEPT(20, 待接单), COOKING(30, 制作中), WAIT_PICKUP(40, 待取餐), DELIVERING(50, 配送中), FINISHED(60, 已完成), CANCELED(70, 已取消); private final int code; private final String desc; // 允许的流转方向key 是当前状态value 是可达状态 private static final MapOrderStatus, SetOrderStatus FLOW Map.of( WAIT_PAY, Set.of(WAIT_ACCEPT, CANCELED), WAIT_ACCEPT, Set.of(COOKING, CANCELED), COOKING, Set.of(WAIT_PICKUP, CANCELED), WAIT_PICKUP, Set.of(DELIVERING), DELIVERING, Set.of(FINISHED), FINISHED, Set.of(), CANCELED, Set.of() ); public static void assertTransfer(OrderStatus from, OrderStatus to) { if (!FLOW.getOrDefault(from, Set.of()).contains(to)) { throw new BizException(订单状态不允许从 from.desc 变为 to.desc); } } // 构造、getter 略 }状态更新一定要带上前置状态做条件UPDATE t_order SET status 30 WHERE id ? AND status 20靠影响行数判断有没有抢到。只写WHERE id ?的话商家端和系统自动取消同时操作后到的会把先到的覆盖掉出现“已取消的订单又被接单了”这种玄学问题。超时取消有两条常见路线。量小的项目用定时任务扫表每分钟扫一次status 10 AND create_time NOW() - INTERVAL 15 MINUTE配合前面那个idx_status_ctime索引几千单量毫无压力。量大就换成延迟消息下单成功时投一条 15 分钟后的消息消费时再查一次当前状态做二次确认——消息可能因为重启丢失所以延迟消息和扫表兜底我一般是两个都留。4.3 商家接单与派单一个 Redis 原子操作解决抢单商家接单之后订单进入待取餐同时要触发派单。派单有两种模式系统指派和骑手抢单。源码里多数是抢单因为实现简单而且骑手端体验好。抢单的核心就一句别让两个骑手抢到同一单。public boolean grabOrder(Long orderId, Long riderId) { // SETNX 抢锁谁先把 key 写进去谁拿到这单过期时间防死锁 Boolean ok redisTemplate.opsForValue() .setIfAbsent(dispatch:lock: orderId, String.valueOf(riderId), 30, TimeUnit.SECONDS); if (Boolean.FALSE.equals(ok)) { return false; // 手慢了直接返回别抛异常 } try { // 数据库再兜一层只有待取餐且未派单的订单才能被绑定 int rows orderMapper.bindRider(orderId, riderId); if (rows 0) { return false; } deliveryMapper.insert(buildDelivery(orderId, riderId)); // 通知用户端骑手已接单走 WebSocket 推送 pushService.notifyUser(orderId, 骑手已接单); return true; } finally { redisTemplate.delete(dispatch:lock: orderId); } }bindRider对应的 SQL 是UPDATE t_order SET rider_id ?, status 50 WHERE id ? AND status 40 AND rider_id IS NULL。Redis 锁只是第一道闸真正的正确性靠数据库这行带条件的更新——即使 Redis 挂了、锁提前过期后面这一层也不会让同一单绑两个骑手。这是我特别想强调的一点缓存锁可以省数据库的乐观校验不能省。派单广播怎么做骑手端连的是 WebSocket服务端维护merchantId/区域 → session的映射。新单产生时按区域推给在线的骑手离线骑手靠轮询拉。别把全国骑手塞进一个广播组推送风暴能把网关打满。5. 避坑排查这套四端代码跑不通时先看这几处下面几条都是我在类似项目里真实踩过的按“现象 → 原因 → 解决”写遇到问题可以先对着排一遍。5.1 服务注册与网关类问题现象所有服务在 Nacos 里都是健康状态但通过网关调任何接口都返回 503直连服务端口却正常。原因网关的spring.cloud.gateway.routes[].uri写的是lb://order-service而订单服务注册上去的名字是order-service加上了配置里的namespace或group不一致网关在默认命名空间里找不到实例。解决先在 Nacos 控制台确认服务列表里的分组和命名空间再回头对网关的discovery.locator配置。顺手检查一下有没有开spring.cloud.gateway.discovery.locator.enabled开了的话路由会自动按服务名生成手写的那份反而可能重复冲突。现象网关转发过去了下游服务拿到userId永远是 null接口全走成未登录。原因网关的全局过滤器里解析了 JWT 并放进请求头但下游服务的过滤器或者拦截器把整个 header 重新读了一遍或者网关的RemoveRequestHeader把自定义头删了。解决在网关过滤器里加一行日志打出最终 header再在下游 Controller 里打一次两头一对就知道被谁吃了。自定义头的名字别用下划线某些容器会直接丢弃带下划线的 header这个坑很阴。5.2 数据库、事务与消息类问题现象下单接口偶发Duplicate entry或者同一用户出现两笔一模一样的订单。原因幂等键只在客户端生成一次但没随请求带上或者服务端用的是“先查 Redis 再写”的写法并发下两个请求同时查到不存在。解决换成setIfAbsent原子写并把幂等键落一列唯一索引做最终兜底。前端按钮也要置灰但别指望它能挡住恶意请求。现象商家端显示“待接单”用户端却显示“已取消”两边数据对不上。原因状态更新没有带前置状态条件定时任务取消和商家接单并发执行后写的覆盖了先写的。解决所有状态变更 SQL 统一加上AND status #{expectStatus}用影响行数判断成败失败就返回“订单状态已变更请刷新”。现象消费端偶尔收不到下单成功的消息商家端不响铃。原因消息是在Transactional方法里发的事务还没提交消息就投出去了消费端回查数据库查不到订单或者生产者发送失败没做重试。解决用TransactionSynchronizationManager.registerSynchronization把发送挪到afterCommit或者干脆落一张本地消息表定时补发。这一条是分布式事务里性价比最高的做法。5.3 联调与前端对接类问题现象浏览器调接口报 CORS 错误POST 请求还多了一次 OPTIONS 预检。原因在业务服务里加了CrossOrigin但请求实际打在网关上网关没有统一处理跨域。解决跨域统一放网关用CorsWebFilter配一次业务服务里的跨域注解全部删掉。注意allowedOrigins不要图省事写*又同时开allowCredentials浏览器会直接拒绝。现象骑手端 WebSocket 连上几秒就断重连循环。原因网关默认的 WebSocket 转发超时短或者 Nginx 那层没配proxy_read_timeout。解决网关的spring.cloud.gateway.httpclient.response-timeout调大前端加心跳包30 秒一次服务端收到心跳回 pong别指望 TCP 自己保活。现象列表接口翻到后面越来越慢第一页秒开。原因用了LIMIT offset, size深翻页offset 到了一万之后 MySQL 要扫掉前面所有行。解决改成游标翻页用上一页最后一条的create_time id作为条件WHERE (create_time, id) (?, ?)配合联合索引翻到第几页都是毫秒级。6. 二次开发加一个「预约单」功能怎么改需求很常见用户不想现在吃想约今晚 7 点送到。改起来不难但涉及表、接口、状态机、定时任务四处正好用来练手。第一步加字段别新建表。t_order里加order_type1 即时2 预约和expect_time预约送达时间顺手补一个索引idx_type_expect (order_type, expect_time, status)这个索引是给后面的调度任务用的。第二步改下单入参和校验。expectTime为空就是即时单走原来的逻辑不为空时校验时间必须大于当前时间 30 分钟、小于 24 小时超范围直接拒掉。这一步放在参数校验层Valid加自定义注解比放在 Service 里干净报错信息也更友好。第三步拆分状态流转。预约单不能下单就进WAIT_ACCEPT否则商家立刻就开始做了。要么加一个WAIT_START状态要么复用WAIT_ACCEPT但给商家端加一层过滤expect_time在当前时间 20 分钟以内的单才推到“待接单”列表。我倾向后者状态机不加节点改的是一个查询条件回滚成本低。第四步加调度。定时任务每 5 分钟扫一次SELECT * FROM t_order WHERE order_type 2 AND status 20 AND expect_time BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 20 MINUTE)扫到的单推到商家端的实时列表里同时给商家发一条提醒。这里要注意幂等——任务重跑不能重复推送用 Redis 记一个push:order:{orderId}的标记带上过期时间。XxlJob(reserveOrderDispatch) public void dispatchReserveOrder() { ListOrder list orderMapper.selectReadyToDispatch(20); // 参数是分钟窗口 for (Order order : list) { String key push:order: order.getId(); // 只有首次设值成功才推送任务重跑不会重复打扰商家 if (Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, 1, 2, TimeUnit.HOURS))) { pushService.notifyMerchant(order.getMerchantId(), order.getId()); } } }验证方式我一般分三步走。先在数据库里手动插一条expect_time为 10 分钟后的预约单看任务跑完商家端有没有收到再把时间改到 2 小时后确认它不会提前推最后压一下连着插 200 条同时间段的预约单看推送耗时和商家端渲染会不会卡。第三步最容易翻车逐条推送在几百单的时候就会明显变慢改成批量推送一次只发一个包含订单列表的消息商家端体验会好很多。扩展点还可以再往前想一步预约单做出来了商家侧的“预约产能”就绕不开——同一时段最多接多少单超了要提示。这个在t_merchant上加一个slot_capacity字段派单前查一次该时段已排单量就能挡住比事后让商家手动拒单体面得多。我在这种四端项目上改功能养成了一个固定习惯动手之前先把要碰的表和要经过的服务在纸上画一遍画完了再写第一行代码。有一次图快直接上手加字段结果漏了订单详情接口的 DTO 转换前端拿到的expectTime永远是 null排查了一下午才发现是序列化那一层没带上。从那以后每次加字段我都强制走一遍“表 → 实体 → DTO → VO → 前端字段”的链路五个点全勾上才算改完。希望这份拆解能帮到你少走几段回头路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站