问题背景
零售门店同时接入美团、饿了么、抖音小时达等多个外卖平台后,面临订单调度的"三重割裂":
| 割裂类型 | 具体表现 | 运营痛点 |
|---|---|---|
| 订单分散 | 各平台订单独立推送,店员需同时盯守3-4个APP | 高峰期漏看订单,超时自动取消率8%+ |
| 调度低效 | 人工判断骑手位置、手动分配订单,平均耗时40秒/单 | 骑手空跑、取货等待时间长,投诉率上升 |
| 状态不同步 | 订单接单、出餐、骑手取货状态需人工在各平台分别操作 | 操作遗漏导致平台处罚,月均损失200-300元 |
中小商户亟需一套轻量级自动调度系统,实现"订单进来→自动分配骑手→状态同步平台"的闭环。本文聚焦该场景的技术实现,方案已在部分零售SaaS系统(如嘚嘚象)中验证,本文仅讨论可复用的技术架构与代码实践。
一、自动调度系统架构
1.1 整体架构设计

1.2 核心设计原则
| 原则 | 说明 | 技术实现 |
|---|---|---|
| 平台解耦 | 新增平台仅需扩展适配器,不修改调度核心 | 策略模式 + 适配器接口 |
| 骑手聚合 | 对接多家跑腿平台,自动选择最优服务商 | 服务商评分模型 + 备份路由 |
| 状态闭环 | 订单全生命周期自动同步,减少人工操作 | 状态机 + 异步回调 |
| 异常兜底 | 骑手拒单/超时自动重派,保障履约 | 超时检测 + 重试队列 |
二、订单聚合与标准化
2.1 多平台订单统一接入
采用适配器模式屏蔽平台差异,核心代码仅需实现OrderAdapter接口:
java
// 订单适配器接口
public interface OrderAdapter {
String getPlatformCode(); // meituan/eleme/douyin
// 接收平台推送的原始订单
UnifiedOrder convertToUnified(String rawOrder);
// 回传订单状态至平台
boolean syncStatus(String orderId, OrderStatus status, String reason);
}
// 美团订单适配器
@Component
@PlatformAdapter("meituan")
public class MeituanOrderAdapter implements OrderAdapter {
@Override
public String getPlatformCode() {
return "meituan";
}
@Override
public UnifiedOrder convertToUnified(String rawJson) {
// 1. 验签(美团要求)
if (!verifySignature(rawJson)) {
throw new InvalidOrderException("美团订单验签失败");
}
// 2. 解析美团特有字段
MeituanOrder mtOrder = JsonUtils.fromJson(rawJson, MeituanOrder.class);
// 3. 转换为统一模型
UnifiedOrder order = new UnifiedOrder();
order.setPlatform("meituan");
order.setOrderId(mtOrder.getOrderId());
order.setStoreId(mtOrder.getStoreId());
order.setTotalAmount(new BigDecimal(mtOrder.getTotalPrice()).divide(BigDecimal.valueOf(100))); // 美团单位为分
order.setCustomerAddress(mtOrder.getAddress());
order.setCustomerPhone(maskPhone(mtOrder.getPhone())); // 脱敏
order.setItems(convertItems(mtOrder.getItems()));
order.setCreateTime(LocalDateTime.parse(mtOrder.getCreateTime()));
order.setExpectedArrivalTime(LocalDateTime.parse(mtOrder.getExpectArrivalTime()));
// 4. 提取特殊字段:美团有"期望送达时间"
order.setExtraField("expect_arrival_time", mtOrder.getExpectArrivalTime());
return order;
}
@Override
public boolean syncStatus(String orderId, OrderStatus status, String reason) {
// 美团状态映射
String mtStatus = mapToMeituanStatus(status);
return meituanApiClient.updateOrderStatus(orderId, mtStatus, reason);
}
private String mapToMeituanStatus(OrderStatus status) {
return switch (status) {
case CONFIRMED -> "confirm"; // 已接单
case PREPARING -> "preparing"; // 备餐中
case PICKED_UP -> "dispatching"; // 骑手取货
case DELIVERED -> "delivered"; // 已送达
default -> "cancel";
};
}
}
// 饿了么订单适配器(类似实现)
@Component
@PlatformAdapter("eleme")
public class ElemeOrderAdapter implements OrderAdapter {
// 饿了么特有逻辑:字段命名、状态码不同
}
2.2 订单去重与合并
同一用户在多平台下单可能导致重复,需基于"用户+地址+商品"做智能去重:
java
@Component
public class OrderDeduplicator {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 检查订单是否重复(5分钟内相同用户+地址+商品组合)
*/
public boolean isDuplicate(UnifiedOrder order) {
// 1. 生成去重Key:用户手机号(脱敏后6位) + 地址哈希 + 商品组合哈希
String userKey = order.getCustomerPhone().substring(order.getCustomerPhone().length() - 6);
String addressHash = DigestUtils.md5Hex(order.getCustomerAddress()).substring(0, 8);
String itemsHash = DigestUtils.md5Hex(JsonUtils.toJson(order.getItems())).substring(0, 8);
String dedupKey = String.format("dedup:%s:%s:%s", userKey, addressHash, itemsHash);
// 2. Redis原子操作:SETNX + EXPIRE
Boolean exists = redisTemplate.opsForValue().setIfAbsent(
dedupKey,
order.getOrderId(),
5, TimeUnit.MINUTES
);
// SETNX返回false表示已存在(重复订单)
return Boolean.FALSE.equals(exists);
}
/**
* 合并相似订单(同一用户5分钟内多笔小额订单)
* 适用场景:用户先下一单,发现漏买又下一单
*/
public UnifiedOrder mergeSimilarOrders(List<UnifiedOrder> orders) {
if (orders.size() <= 1) return null;
// 1. 检查是否同一用户、同一地址、时间间隔<5分钟
UnifiedOrder first = orders.get(0);
boolean canMerge = orders.stream().allMatch(o ->
o.getCustomerPhone().equals(first.getCustomerPhone()) &&
o.getCustomerAddress().equals(first.getCustomerAddress()) &&
Duration.between(o.getCreateTime(), first.getCreateTime()).toMinutes() < 5
);
if (!canMerge) return null;
// 2. 合并商品(相同商品数量累加)
Map<String, OrderItem> mergedItems = new HashMap<>();
for (UnifiedOrder order : orders) {
for (OrderItem item : order.getItems()) {
String skuId = item.getSkuId();
if (mergedItems.containsKey(skuId)) {
OrderItem existing = mergedItems.get(skuId);
existing.setQuantity(existing.getQuantity() + item.getQuantity());
} else {
mergedItems.put(skuId, item.deepCopy());
}
}
}
// 3. 生成合并订单
UnifiedOrder merged = first.deepCopy();
merged.setOrderId("MERGED_" + System.currentTimeMillis());
merged.setItems(new ArrayList<>(mergedItems.values()));
merged.setTotalAmount(orders.stream()
.map(UnifiedOrder::getTotalAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add));
merged.setExtraField("merged_order_ids",
orders.stream().map(UnifiedOrder::getOrderId).collect(Collectors.joining(",")));
return merged;
}
}
三、骑手智能调度算法
3.1 骑手位置实时管理
基于Redis Geo实现骑手位置实时更新与查询:
java
@Component
public class RiderLocationManager {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// Redis Key: rider:geo -> 存储骑手地理位置
// rider:status:{riderId} -> 骑手状态(空闲/接单中/休息)
/**
* 更新骑手位置(骑手APP每30秒上报一次)
*/
public void updateLocation(String riderId, double lng, double lat) {
// 1. 更新Geo
redisTemplate.opsForGeo().add(
"rider:geo",
new Point(lng, lat),
riderId
);
// 2. 更新最后上报时间(用于判断是否离线)
redisTemplate.opsForValue().set(
"rider:last_update:" + riderId,
String.valueOf(System.currentTimeMillis()),
5, TimeUnit.MINUTES
);
}
/**
* 查询附近空闲骑手(3公里内)
*/
public List<Rider> findNearbyRiders(String storeId, int maxCount) {
// 1. 获取门店位置
Store store = storeService.getStore(storeId);
Circle circle = new Circle(new Point(store.getLng(), store.getLat()),
new Distance(3, Metrics.KILOMETERS));
// 2. Geo查询 + 状态过滤
GeoResults<RedisGeoCommands.GeoLocation<String>> results =
redisTemplate.opsForGeo().radius("rider:geo", circle,
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
.includeCoordinates()
.includeDistance()
.sortAscending() // 按距离升序
.limit(maxCount));
// 3. 过滤空闲骑手
List<Rider> riders = new ArrayList<>();
for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
String riderId = result.getContent().getName();
// 检查骑手状态是否空闲
String status = redisTemplate.opsForValue().get("rider:status:" + riderId);
if ("IDLE".equals(status)) {
Rider rider = new Rider();
rider.setRiderId(riderId);
rider.setDistance(result.getDistance().getValue()); // 距离(公里)
rider.setLng(result.getContent().getPoint().getX());
rider.setLat(result.getContent().getPoint().getY());
riders.add(rider);
}
}
return riders;
}
/**
* 标记骑手接单(状态变更为BUSY)
*/
public void markRiderBusy(String riderId, String orderId) {
redisTemplate.opsForValue().set("rider:status:" + riderId, "BUSY", 30, TimeUnit.MINUTES);
redisTemplate.opsForValue().set("rider:current_order:" + riderId, orderId);
}
/**
* 标记骑手空闲(订单完成后)
*/
public void markRiderIdle(String riderId) {
redisTemplate.opsForValue().set("rider:status:" + riderId, "IDLE", 24, TimeUnit.HOURS);
redisTemplate.delete("rider:current_order:" + riderId);
}
}
3.2 订单分配算法
采用加权评分模型,综合距离、骑手负载、历史履约率:
java
@Component
public class OrderAssigner {
/**
* 分配骑手(返回最优骑手ID)
*/
public String assignRider(UnifiedOrder order) {
// 1. 查询附近空闲骑手(3公里内,最多20人)
List<Rider> nearbyRiders = riderLocationManager.findNearbyRiders(
order.getStoreId(), 20
);
if (nearbyRiders.isEmpty()) {
log.warn("附近无空闲骑手, orderId={}", order.getOrderId());
return null; // 无骑手可用,后续走人工调度
}
// 2. 计算每个骑手的综合得分
Map<String, Double> scores = new HashMap<>();
for (Rider rider : nearbyRiders) {
double score = calculateRiderScore(rider, order);
scores.put(rider.getRiderId(), score);
}
// 3. 选择得分最高的骑手
return scores.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(null);
}
/**
* 计算骑手综合得分(0-100分)
* 权重:距离40% + 负载30% + 履约率30%
*/
private double calculateRiderScore(Rider rider, UnifiedOrder order) {
// 1. 距离得分(越近得分越高,3公里内满分)
double distanceScore = Math.max(0, 100 - rider.getDistance() * 20);
// 2. 负载得分(当前订单数越少得分越高)
int currentOrders = getCurrentOrderCount(rider.getRiderId());
double loadScore = currentOrders == 0 ? 100 :
currentOrders == 1 ? 80 :
currentOrders == 2 ? 60 : 40;
// 3. 履约率得分(近7天准时送达率)
double fulfillmentRate = getFulfillmentRate(rider.getRiderId());
double rateScore = fulfillmentRate * 100;
// 4. 加权综合得分
return distanceScore * 0.4 + loadScore * 0.3 + rateScore * 0.3;
}
private int getCurrentOrderCount(String riderId) {
// 从Redis获取骑手当前订单数
String countStr = redisTemplate.opsForValue().get("rider:order_count:" + riderId);
return countStr == null ? 0 : Integer.parseInt(countStr);
}
private double getFulfillmentRate(String riderId) {
// 从数据库查询近7天履约数据
RiderStats stats = riderStatsMapper.getRecentStats(riderId, 7);
if (stats.getTotalOrders() == 0) return 0.9; // 新骑手默认90%
return (double) stats.getOnTimeDeliveries() / stats.getTotalOrders();
}
}
四、跑腿平台聚合调度
4.1 多平台SDK统一封装
对接闪送、达达、顺丰同城等跑腿平台,封装统一调用接口:
java
// 跑腿平台接口
public interface DeliveryPlatform {
String getPlatformCode(); // shansong/dada/sf
// 下单
DeliveryOrder createOrder(DeliveryRequest request) throws DeliveryException;
// 取消订单
boolean cancelOrder(String deliveryOrderId);
// 查询订单状态
DeliveryStatus queryStatus(String deliveryOrderId);
}
// 闪送平台实现
@Component
@DeliveryPlatformType("shansong")
public class ShansongPlatform implements DeliveryPlatform {
@Override
public String getPlatformCode() {
return "shansong";
}
@Override
public DeliveryOrder createOrder(DeliveryRequest request) {
// 1. 转换为闪送特有格式
ShansongCreateOrderReq ssReq = convertToShansong(request);
// 2. 调用闪送API
ShansongCreateOrderResp resp = shansongClient.createOrder(ssReq);
// 3. 转换为统一模型
DeliveryOrder order = new DeliveryOrder();
order.setPlatform("shansong");
order.setDeliveryOrderId(resp.getOrderId());
order.setRiderName(resp.getRiderName());
order.setRiderPhone(resp.getRiderPhone());
order.setEstimatedArrivalTime(resp.getEstimatedTime());
order.setStatus("PICKING_UP"); // 闪送状态:取件中
return order;
}
// ... 其他方法实现
}
// 达达平台实现(类似)
@Component
@DeliveryPlatformType("dada")
public class DadaPlatform implements DeliveryPlatform {
// 达达特有逻辑
}
// 平台工厂
@Component
public class DeliveryPlatformFactory {
@Autowired
private List<DeliveryPlatform> platforms;
private Map<String, DeliveryPlatform> platformMap;
@PostConstruct
public void init() {
platformMap = platforms.stream()
.collect(Collectors.toMap(DeliveryPlatform::getPlatformCode, Function.identity()));
}
public DeliveryPlatform getPlatform(String platformCode) {
return platformMap.get(platformCode);
}
}
4.2 智能路由策略
根据订单特性自动选择最优跑腿平台:
java
@Component
public class DeliveryRouter {
@Autowired
private DeliveryPlatformFactory platformFactory;
/**
* 智能路由:选择最优跑腿平台
*/
public String routePlatform(UnifiedOrder order) {
// 1. 获取所有可用平台
List<PlatformScore> scores = new ArrayList<>();
// 2. 闪送:适合高客单价、紧急订单
if (order.getTotalAmount().compareTo(BigDecimal.valueOf(50)) >= 0) {
scores.add(new PlatformScore("shansong", 90)); // 高客单价优先闪送
} else {
scores.add(new PlatformScore("shansong", 60));
}
// 3. 达达:覆盖广、价格适中,基础分80
scores.add(new PlatformScore("dada", 80));
// 4. 顺丰同城:适合贵重商品,但价格高
if (isValuableGoods(order)) {
scores.add(new PlatformScore("sf", 85));
} else {
scores.add(new PlatformScore("sf", 50)); // 非贵重商品不优先
}
// 5. 实时价格查询(可选):调用各平台预估接口,选择最低价
// scores.forEach(score -> {
// BigDecimal price = queryEstimatedPrice(score.getPlatform(), order);
// score.setPrice(price);
// score.adjustScoreByPrice(price); // 价格越低,分数越高
// });
// 6. 选择最高分平台
return scores.stream()
.max(Comparator.comparingDouble(PlatformScore::getScore))
.map(PlatformScore::getPlatform)
.orElse("dada"); // 默认达达
}
private boolean isValuableGoods(UnifiedOrder order) {
// 判断是否含贵重商品(如成人用品中的高端品类、数码配件)
return order.getItems().stream()
.anyMatch(item -> item.getCategory() != null &&
item.getCategory().contains("高端") ||
item.getPrice().compareTo(BigDecimal.valueOf(100)) > 0);
}
@Data
@AllArgsConstructor
static class PlatformScore {
private String platform;
private double score;
private BigDecimal price; // 可选:预估价格
public void adjustScoreByPrice(BigDecimal price) {
// 价格每高5元,分数-2分
if (this.price != null) {
double diff = price.subtract(this.price).doubleValue() / 5;
this.score -= diff * 2;
}
}
}
}
4.3 骑手拒单自动重派
骑手接单后可能拒单或超时未取货,需自动重派:
java
@Component
public class AutoReassignService {
// 订单状态机
// PENDING → ASSIGNED → [RIDER_ACCEPTED | RIDER_REJECTED | TIMEOUT] → REASSIGNING → ...
/**
* 处理骑手拒单
*/
@EventListener
public void onRiderRejected(RiderRejectedEvent event) {
String orderId = event.getOrderId();
String rejectedRiderId = event.getRiderId();
// 1. 标记骑手空闲
riderLocationManager.markRiderIdle(rejectedRiderId);
// 2. 记录拒单次数(防恶意拒单)
incrementRejectionCount(rejectedRiderId);
// 3. 重新分配骑手(排除刚拒单的骑手)
Set<String> excludedRiders = new HashSet<>();
excludedRiders.add(rejectedRiderId);
String newRiderId = orderAssigner.assignRiderExcluding(orderId, excludedRiders);
if (newRiderId != null) {
assignOrderToRider(orderId, newRiderId);
} else {
// 无可用骑手,转人工调度
alertService.notifyStore(orderId, "无可用骑手,请人工调度");
}
}
/**
* 处理取货超时(骑手接单后15分钟未取货)
*/
@Scheduled(fixedDelay = 60000) // 每分钟检查
public void checkPickupTimeout() {
List<TimeoutOrder> timeoutOrders = orderMapper.findPickupTimeoutOrders(15); // 15分钟未取货
for (TimeoutOrder order : timeoutOrders) {
// 1. 取消当前骑手订单(调用跑腿平台API)
deliveryService.cancelOrder(order.getDeliveryOrderId());
// 2. 标记骑手空闲
riderLocationManager.markRiderIdle(order.getRiderId());
// 3. 重新分配
String newRiderId = orderAssigner.assignRider(order.getUnifiedOrder());
if (newRiderId != null) {
assignOrderToRider(order.getOrderId(), newRiderId);
}
}
}
private void assignOrderToRider(String orderId, String riderId) {
// 1. 调用跑腿平台下单
DeliveryOrder deliveryOrder = deliveryService.createOrder(orderId, riderId);
// 2. 更新订单状态
orderService.updateStatus(orderId, "RIDER_ASSIGNED",
"骑手" + deliveryOrder.getRiderName() + "已接单");
// 3. 标记骑手忙碌
riderLocationManager.markRiderBusy(riderId, orderId);
// 4. 通知门店(语音播报)
voiceService.broadcast("骑手" + deliveryOrder.getRiderName() + "已接单,电话" + deliveryOrder.getRiderPhone());
}
}
五、状态自动同步至外卖平台
订单状态变化需自动回传至美团、饿了么等平台,减少人工操作:
java
@Component
public class OrderStatusSyncService {
@Autowired
private PlatformAdapterFactory platformAdapterFactory;
/**
* 订单状态变更事件监听
*/
@EventListener
@Async // 异步执行,避免阻塞主流程
public void onOrderStatusChanged(OrderStatusChangedEvent event) {
UnifiedOrder order = event.getOrder();
OrderStatus newStatus = event.getNewStatus();
// 1. 获取平台适配器
OrderAdapter adapter = platformAdapterFactory.getAdapter(order.getPlatform());
if (adapter == null) return;
// 2. 映射状态并同步
try {
boolean success = adapter.syncStatus(order.getOrderId(), newStatus, event.getReason());
if (!success) {
// 同步失败,加入重试队列
retryQueue.offer(new SyncRetryTask(order.getPlatform(), order.getOrderId(), newStatus));
}
} catch (Exception e) {
log.error("状态同步失败, platform={}, orderId={}",
order.getPlatform(), order.getOrderId(), e);
// 失败加入重试队列
retryQueue.offer(new SyncRetryTask(order.getPlatform(), order.getOrderId(), newStatus));
}
}
/**
* 重试任务(指数退避)
*/
@Scheduled(fixedDelay = 30000) // 每30秒检查重试队列
public void processRetryQueue() {
SyncRetryTask task = retryQueue.poll();
if (task == null) return;
// 指数退避:第1次10秒,第2次20秒,第3次40秒...
if (System.currentTimeMillis() - task.getCreateTime() < task.getRetryInterval()) {
retryQueue.offer(task); // 未到重试时间,放回队列
return;
}
// 执行重试
try {
OrderAdapter adapter = platformAdapterFactory.getAdapter(task.getPlatform());
boolean success = adapter.syncStatus(task.getOrderId(), task.getStatus(), "系统重试");
if (!success && task.getRetryCount() < 3) {
// 重试次数+1,间隔翻倍
task.incrementRetry();
retryQueue.offer(task);
}
// 超过3次放弃(避免无限重试)
} catch (Exception e) {
log.error("重试同步失败", e);
if (task.getRetryCount() < 3) {
task.incrementRetry();
retryQueue.offer(task);
}
}
}
}
六、实际落地要点
6.1 门店部署方案
| 组件 | 部署方式 | 说明 |
|---|---|---|
| 云端服务 | 公有云(2核4G) | 订单聚合、调度算法、平台对接 |
| 门店终端 | 旧Android手机 + 普通音箱 | 语音播报骑手信息,无需专用硬件 |
| 网络要求 | 门店WiFi | 仅需稳定上网,无特殊带宽要求 |
| 部署时间 | <30分钟 | 扫码安装APP + 配置门店ID |
6.2 异常场景处理
| 异常场景 | 处理策略 | 技术实现 |
|---|---|---|
| 骑手拒单 | 自动重派 + 拉黑高频拒单骑手 | 拒单计数器 + 黑名单 |
| 平台API故障 | 本地缓存订单 + 故障恢复后重试 | 本地队列 + 指数退避重试 |
| 网络中断 | 边缘端缓存订单,网络恢复后同步 | SQLite本地存储 |
| 骑手失联 | 15分钟未取货自动取消并重派 | 定时任务扫描超时订单 |
总结
订单自动调度系统的技术价值在于用轻量级方案解决高频运营痛点,核心经验:
- 适配器模式解耦平台差异
新增外卖平台或跑腿平台时,仅需扩展适配器实现类,调度核心逻辑零修改。 - 边缘轻量化设计
门店端仅需旧手机运行轻量APP,负责语音播报与状态展示,核心调度在云端完成。 - 状态自动闭环
从"订单到达→分配骑手→骑手取货→送达"全链路自动同步平台,人工操作减少80%。
该方案已在多家便利店、成人用品店落地验证,无需专用硬件投入,仅需复用门店现有手机+网络即可实现自动化调度。技术落地的关键不是追求算法最优,而是"在约束条件下提供稳定可用的解决方案"------对于中小商户,稳定、低成本、易维护比技术先进性更重要。
注:本文仅讨论订单自动调度的技术实现方案,所有组件基于开源技术栈。文中提及的行业实践(如部分零售SaaS系统采用类似架构)仅为技术存在性佐证,不构成商业产品推荐。实际部署需结合具体平台API规范与业务规则调整。