简介:这是一套面向同城跑腿与校园配送场景的全开源技术方案,基于Fastadmin、ThinkPHP与Uniapp开发,包含用户端、骑手端和运营后台三端,适合跑腿创业团队、外包开发者及需要私有化部署的运营方使用。系统支持帮取、帮送两种业务模式,并内置一键抢单、主动接单、自由开工、系统派单与智能派单等完整调度能力,可按骑手距离、送货地址与等级自动推送订单。计价层面覆盖按距离、重量计费,支持临时加价、预约取件、跑腿小费与物品保价,配合地图选点、距离计算与路线导航,形成从下单到履约的闭环。资源以zip压缩包形式提供,整体约64.82MB,源码无加密,便于二次开发与私有化部署。目前已有94人学习关注,适合希望快速搭建跑腿平台、研究派单算法与多端协同实现的技术人员参考。1. 跑腿小程序智能派单系统:从下单到骑手接单,中间到底发生了什么你在校园里点了一份外卖代取,或者在同城下了一单急送,从点击“下单”到骑手出现在你面前,中间可能只隔了几十秒。但就是这几十秒里,系统要做完一整套决策:订单该派给谁、为什么派给他、派错了怎么办。跑腿小程序智能派单系统要解决的核心问题,就是把这套决策从“人工调度拍脑袋”变成“算法自动算”。它适合两类人:一类是想做校园跑腿或同城配送产品的开发者,需要一套能跑通的用户端加骑手端闭环;另一类是已经在做配送系统、但派单还靠手动或简单轮询的团队,想升级成智能派单。全开源意味着你能看到订单流转、骑手状态、派单策略的完整链路,而不是只调一个黑匣子接口。这一章先把“智能派单”这四个字拆开,说清楚它到底在算什么。智能派单的本质是一个实时匹配问题。用户下单时带着取件地址、送达地址、时间要求、物品重量;骑手端持续上报位置、接单状态、当前负载。系统要在毫秒级内从在线骑手里选出一个“最合适”的,把订单推给他。这里的“合适”不是单一维度,而是距离、顺路程度、骑手当前单量、历史准时率、甚至骑手等级的综合评分。校园场景和同城场景的差别也在这里:校园里楼栋密集、骑手多是学生兼职、订单集中在饭点和快递高峰;同城场景距离拉长、路况复杂、骑手全职比例高。一套系统要同时覆盖,派单策略就必须可配置,而不是写死一个公式。全开源的价值在于,你能看到派单评分函数是怎么写的、骑手状态机是怎么流转的、订单超时后是怎么重新派单的。这些细节在闭源 SaaS 里通常被藏起来,出了问题只能等客服。而自己掌握源码,你可以根据校园的楼栋分布调整距离权重,根据兼职骑手的接单习惯调整推送时机。接下来的章节会从数据模型、派单算法、用户端和骑手端的实现、以及部署踩坑几个角度,把这套系统拆到能照着复现的程度。2. 派单系统的数据模型与状态机:订单和骑手怎么被描述2.1 订单状态机:从待派单到已完成要经过几个状态订单不是一张静态表,它有一条明确的状态流转路径。常见做法是定义六到八个状态:待支付、待派单、已派单、已接单、取件中、配送中、已送达、已完成,外加一个异常关闭。每个状态之间的跃迁都要有触发条件和超时兜底。比如“待派单”超过 30 秒没有骑手接单,就要触发重新派单或扩大派单范围;“已派单”超过 60 秒骑手未确认,订单要回退到待派单并给骑手记一次未响应。用数据库表来落地时,订单主表至少要有这些字段:订单号、用户 ID、骑手 ID、取件地址经纬度、送达地址经纬度、订单状态、派单次数、创建时间、派单时间、接单时间、取件时间、送达时间。派单次数这个字段很关键,它决定了系统要不要给订单加急或加价。下面是一个简化的建表语句,用 MySQL 语法:CREATE TABLE `delivery_order` ( `order_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单主键', `order_no` VARCHAR(32) NOT NULL COMMENT '对外订单号', `user_id` BIGINT NOT NULL COMMENT '下单用户ID', `rider_id` BIGINT DEFAULT NULL COMMENT '接单骑手ID,未派单为空', `pickup_lat` DECIMAL(10,7) NOT NULL COMMENT '取件纬度', `pickup_lng` DECIMAL(10,7) NOT NULL COMMENT '取件经度', `deliver_lat` DECIMAL(10,7) NOT NULL COMMENT '送达纬度', `deliver_lng` DECIMAL(10,7) NOT NULL COMMENT '送达经度', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待派单 1已派单 2已接单 3取件中 4配送中 5已送达 6已完成 9异常关闭', `dispatch_count` INT NOT NULL DEFAULT 0 COMMENT '被派单次数', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `dispatch_time` DATETIME DEFAULT NULL COMMENT '最近一次派单时间', `accept_time` DATETIME DEFAULT NULL COMMENT '骑手接单时间', `finish_time` DATETIME DEFAULT NULL COMMENT '送达时间', PRIMARY KEY (`order_id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status_dispatch` (`status`, `dispatch_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='配送订单主表';逻辑说明:status 用数字枚举而不是字符串,是为了在索引和范围查询时更快;dispatch_count 配合 dispatch_time 可以筛出“派了三次还没人接”的订单;pickup 和 deliver 的经纬度用 DECIMAL(10,7) 而不是 FLOAT,是因为 FLOAT 在距离计算时会有精度漂移,校园里楼栋之间只差几十米,精度不够会导致派错楼。参数上,经纬度精度 7 位大约对应 1 厘米,足够用;如果只做同城粗派,5 位也够,但没必要省这点空间。2.2 骑手状态与位置上报:在线、忙碌、接单上限怎么存骑手端要持续做两件事:上报位置、更新接单状态。骑手表里除了基本信息,关键字段是当前状态、当前接单数、最大接单数、最近一次位置和位置更新时间。当前状态一般分离线、在线空闲、在线忙碌、休息中。在线空闲才能被派单,在线忙碌表示手上有单但还能顺路带,休息中不派单。位置上报不建议直接写 MySQL,高频写入会把订单表拖慢。常见做法是骑手位置先写 Redis,用 GEO 结构存经纬度,键可以是 rider:geo,成员是骑手 ID。派单时用 GEORADIUS 或 GEOSEARCH 查附近骑手,再回表查骑手状态和负载。下面是一段用 Redis 命令模拟位置更新和附近查询的例子:# 更新骑手位置,经度在前纬度在后 GEOADD rider:geo 116.397128 39.916527 rider_1001 GEOADD rider:geo 116.398200 39.917100 rider_1002 # 查询取件点附近 3 公里内的骑手,按距离升序 GEOSEARCH rider:geo FROMLONLAT 116.397000 39.916000 BYRADIUS 3 km ASC WITHDIST逻辑说明:GEOADD 的经纬度顺序容易写反,Redis 要求先经度后纬度,写反了查出来是南极附近。GEOSEARCH 是 Redis 6.2 之后的命令,老版本用 GEORADIUS。BYRADIUS 3 km 表示半径三公里,校园场景可以缩到 1 公里,同城可以放到 5 公里。WITHDIST 会返回距离,方便在评分时直接用。注意 Redis GEO 只负责“谁在附近”,不负责“谁有空”,所以查出来之后还要拿骑手 ID 去查状态和当前单量,过滤掉离线、休息和已达接单上限的骑手。2.3 派单日志表:为什么每次派单都要留痕派单不能只改订单表就完事,每次派单都要写一条日志:订单号、候选骑手列表、最终选中的骑手、评分明细、派单时间、骑手响应结果。这张表在排查“为什么这单派给了他”时是唯一的后悔药。没有日志,用户投诉派单慢、骑手投诉派单不公平,你都拿不出证据。日志表可以这样设计:CREATE TABLE `dispatch_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `candidate_riders` JSON DEFAULT NULL COMMENT '候选骑手及评分快照', `chosen_rider_id` BIGINT DEFAULT NULL COMMENT '最终选中骑手', `score_detail` JSON DEFAULT NULL COMMENT '各维度得分', `dispatch_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `rider_response` TINYINT DEFAULT NULL COMMENT '1接单 2拒绝 3超时未响应', PRIMARY KEY (`id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='派单日志表';candidate_riders 和 score_detail 用 JSON 存,是因为候选骑手数量和评分维度会随策略调整而变化,用固定列会频繁改表。JSON 字段在 MySQL 5.7 以上支持,查询时可以用 JSON_EXTRACT 取特定骑手。代价是 JSON 列不能直接建普通索引,但派单日志主要是按订单号查,订单号有索引就够了。3. 智能派单算法:评分函数怎么写、参数怎么调3.1 派单评分的四个维度:距离、顺路、负载、信用智能派单的核心是一个评分函数,输入是订单和候选骑手,输出是一个分数,分数最高的骑手拿到订单。常见做法是加权求和:总分等于距离得分乘距离权重,加上顺路得分乘顺路权重,加上负载得分乘负载权重,加上信用得分乘信用权重。每个维度的得分都归一化到 0 到 100 之间,权重之和为 1。距离得分最简单:骑手当前位置到取件点的距离越近分越高。可以用一个衰减函数,比如距离 0 米得 100 分,距离 3 公里得 0 分,中间线性衰减。顺路得分要看骑手手上已有订单的送达点和新订单的取件点、送达点是否同方向。一个粗略但有效的算法是比较新订单送达点与骑手当前配送方向的夹角,夹角越小分越高。负载得分是骑手当前接单数越少分越高,但要注意,完全不给忙碌骑手派单会导致高峰期运力浪费,所以负载权重通常设得比距离低。信用得分来自历史数据:准时率、取消率、投诉率,新骑手给一个中性初始分。下面是一个 Python 版的评分函数示例,可以直接嵌到派单服务里:import math def haversine(lat1, lng1, lat2, lng2): """计算两点间距离,单位米""" R = 6371000 phi1, phi2 = math.radians(lat1), math.radians(lat2) dphi = math.radians(lat2 - lat1) dlambda = math.radians(lng2 - lng1) a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2 return 2 * R * math.asin(math.sqrt(a)) def score_rider(order, rider, weights): """ order: 含 pickup_lat/lng, deliver_lat/lng rider: 含 lat/lng, current_orders, max_orders, credit_score, current_direction weights: {'distance':0.4, 'route':0.25, 'load':0.2, 'credit':0.15} """ # 距离得分:0米100分,3000米0分 dist = haversine(rider['lat'], rider['lng'], order['pickup_lat'], order['pickup_lng']) dist_score = max(0, 100 - (dist / 3000) * 100) # 顺路得分:新订单送达方向与骑手当前方向夹角 order_dir = math.atan2(order['deliver_lat'] - order['pickup_lat'], order['deliver_lng'] - order['pickup_lng']) angle_diff = abs(order_dir - rider['current_direction']) angle_diff = min(angle_diff, 2 * math.pi - angle_diff) route_score = max(0, 100 - (angle_diff / math.pi) * 100) # 负载得分:接单越少分越高 load_ratio = rider['current_orders'] / max(1, rider['max_orders']) load_score = max(0, 100 - load_ratio * 100) # 信用得分:直接取历史信用分 credit_score = rider['credit_score'] total = (dist_score * weights['distance'] + route_score * weights['route'] + load_score * weights['load'] + credit_score * weights['credit']) return { 'total': round(total, 2), 'distance': round(dist_score, 2), 'route': round(route_score, 2), 'load': round(load_score, 2), 'credit': round(credit_score, 2) }逻辑说明:haversine 用球面距离公式,比直接算平面距离准,校园小范围其实平面距离也够,但同城场景必须用球面。距离得分里 3000 米是衰减半径,校园可以改成 1000,同城改成 5000。顺路得分用 atan2 算方向角,夹角用弧度表示,注意处理 2π 环绕。负载得分用当前单量除以最大单量,最大单量建议按骑手等级设,兼职骑手设 2 到 3,全职设 5 到 8。信用分初始给 80,准时加、取消扣,范围控制在 0 到 100。权重怎么调是玄学也是血泪经验。校园饭点高峰期,距离权重可以降到 0.3,负载权重提到 0.3,因为骑手都在楼栋附近,距离差别不大,谁手上单少谁跑得快。同城平峰期,距离权重提到 0.5,顺路权重 0.3,因为空跑成本高。调权重不要拍脑袋,用派单日志表回放历史订单,看不同权重下平均接单时长和骑手空驶距离的变化。3.2 派单触发时机:定时轮询还是事件驱动派单触发有两种常见模式。定时轮询是每隔几秒扫一次待派单订单,批量派。事件驱动是订单创建时立即触发派单,骑手拒单或超时也立即触发重派。校园场景订单集中爆发,纯事件驱动会在饭点瞬间打出大量派单请求,把骑手端推送打爆;纯轮询又会让订单等待时间变长。我一般用混合模式:订单创建后先做一次即时派单,如果 10 秒内没骑手接单,进入轮询队列,每 5 秒重派一次,同时逐步扩大搜索半径。重派时要注意,不要给同一个骑手反复推同一单。派单日志表里记下已经推过的骑手,重派时过滤掉。如果一单推了 5 个骑手都没接,系统应该自动加价或转人工调度。加价幅度可以按订单金额的 10% 递增,上限设 50%,避免成本失控。3.3 骑手拒单和超时未响应的处理骑手拒单是派单系统必须面对的现实。拒单原因很多:不顺路、手上有单、单纯不想跑。系统能做的不是阻止拒单,而是快速把订单转给下一个候选。拒单后要立即从候选列表里取下一个骑手推送,同时给拒单骑手记一次拒单次数,拒单率过高的骑手降低信用分或限制接单。超时未响应比拒单更麻烦,因为骑手可能只是没看手机。常见做法是给骑手端推送后启动一个 30 秒的定时器,30 秒内没点接单也没点拒绝,视为超时,订单回退重派,骑手记一次未响应。连续三次未响应的骑手,系统自动置为休息中,需要手动上线。这个逻辑要在服务端做,不能依赖骑手端上报,否则骑手端进程被杀就永远不触发。4. 用户端与骑手端的关键实现:下单、推送、接单、轨迹4.1 用户端下单接口:地址解析和费用预估用户端下单不是简单提交一个表单。用户输入取件地址和送达地址,系统要先把文字地址转成经纬度,再算距离,再根据距离和重量算配送费。地址解析可以调地图服务的 API,也可以让用户在地图上选点。校园场景建议做楼栋下拉,因为学生写地址很随意,“三号楼”“3 栋”“三舍”可能指同一个地方,纯文本解析容易翻车。费用预估的公式一般是起步价加距离费加重量费。起步价覆盖前 1 公里,超出部分每公里加价,重量超过 5 公斤加超重费。预估结果要返回给用户确认,确认后再创建订单。下面是一个下单接口的伪代码结构:def create_order(user_id, pickup_text, deliver_text, weight_kg): # 地址解析,失败则返回让用户手动选点 pickup = geocode(pickup_text) deliver = geocode(deliver_text) if not pickup or not deliver: return {'code': 4001, 'msg': '地址解析失败,请在地图上选点'} distance = haversine(pickup.lat, pickup.lng, deliver.lat, deliver.lng) base_fee = 3.0 # 起步价,覆盖1公里 per_km = 1.5 # 超出部分每公里 heavy_fee = max(0, weight_kg - 5) * 0.5 # 超重费 total_fee = base_fee + max(0, distance/1000 - 1) * per_km + heavy_fee order_no = gen_order_no() insert_order(order_no, user_id, pickup, deliver, total_fee) trigger_dispatch(order_no) # 触发即时派单 return {'code': 0, 'order_no': order_no, 'fee': round(total_fee, 2)}逻辑说明:geocode 返回 None 时不要硬猜,让用户手动选点比派错楼栋强。距离用米,费用计算时转公里。起步价和每公里价格要可配置,不同城市、不同校园可以不一样。trigger_dispatch 是异步调用,不要阻塞下单接口,否则用户会等派单结果才看到下单成功,体验很差。4.2 骑手端接单推送:长连接还是轮询骑手端要实时收到派单通知,技术上有两种选择:WebSocket 长连接和 HTTP 轮询。长连接实时性好,但骑手端在弱网环境下容易断,断了要重连,重连期间派单会丢。轮询实现简单,但频率高了耗电,频率低了延迟大。我一般用长连接加兜底轮询:骑手端保持 WebSocket,同时每 15 秒轮询一次待接单列表,防止长连接假死。推送内容不要只发订单号,要把取件地址、送达地址、距离、费用一起推,骑手才能快速判断接不接。推送格式用 JSON,字段名要短,减少流量。骑手端收到推送后弹窗加声音提醒,弹窗上直接放接单和拒绝按钮,减少操作步骤。4.3 配送轨迹上报与用户端展示骑手接单后要持续上报位置,用户端要能看到骑手到哪了。轨迹上报频率建议 5 到 10 秒一次,太频繁耗电,太稀疏轨迹跳变。位置存 Redis 的 GEO 结构,同时写一份到轨迹表做历史回放。用户端展示时,不要直接画折线,校园里楼栋间直线不可达,画折线会误导用户。常见做法是只显示骑手当前位置图标加预计到达时间,预计时间用直线距离除以平均速度再乘一个路况系数。轨迹表可以按天分表,避免单表过大。字段包括骑手 ID、订单号、经纬度、上报时间。查询时按订单号查,按时间排序。如果用户投诉骑手绕路,轨迹表就是证据。5. 部署与避坑:从本地跑通到线上稳定要过几道坎5.1 本地跑通的最小步骤拿到全开源包后,不要急着改代码,先按最小依赖跑通。常见步骤是:装 MySQL 和 Redis,导入建表 SQL,改配置文件里的数据库和 Redis 地址,启动后端服务,启动用户端和骑手端。如果包里有 Docker Compose 文件,优先用 Docker 起依赖,省去装环境的麻烦。跑通的标准是:用户端能下单,骑手端能收到推送并接单,订单状态能从待派单走到已完成。配置文件里最容易错的是 Redis 地址和端口,以及地图服务的 Key。地图 Key 通常有域名白名单,本地调试要加 127.0.0.1。如果派单一直不触发,先看后端日志里有没有“候选骑手为空”,再看 Redis 里 GEO 结构有没有数据,最后看骑手状态是不是在线空闲。5.2 派单延迟高、骑手收不到推送怎么排查派单延迟高,按这个顺序查:订单是否进入了待派单状态、派单服务是否在运行、Redis 里附近骑手查询是否超时、骑手端长连接是否在线。如果 Redis 查询慢,可能是 GEO 数据量太大,校园场景几千个骑手没问题,同城几十万骑手就要分片,按区域建多个 GEO 键。骑手收不到推送,先确认骑手状态是在线空闲,再确认推送通道是否正常。长连接断了骑手端要自动重连,重连失败要降级到轮询。如果骑手端在后台被系统杀掉,推送也收不到,这种情况只能靠轮询兜底。测试时可以用两个手机,一个下单一个接单,看推送延迟。5.3 订单状态不一致和重复派单订单状态不一致通常是因为并发操作。比如骑手 A 和骑手 B 同时点接单,两个请求都查到订单是已派单状态,都去更新骑手 ID,后更新的覆盖先更新的。解决办法是用数据库乐观锁:更新时带上前一个状态,UPDATE delivery_order SET rider_id=?, status=2 WHERE order_no=? AND status=1,影响行数为 0 说明被别人抢先了,直接返回接单失败。重复派单是另一个坑。定时轮询和事件驱动同时触发,同一订单可能被派两次。解决办法是派单前用 Redis 分布式锁锁住订单号,锁过期时间设 10 秒,派单完成后释放。锁的粒度要细,锁订单不锁整个派单服务,否则高峰期会排队。5.4 校园场景的三个特殊坑第一个坑是楼栋定位漂移。校园里楼栋密集,GPS 漂移几十米就可能派到隔壁楼。解决办法是让用户下单时从楼栋列表选,而不是纯靠定位。骑手端导航到楼栋门口,而不是经纬度点。第二个坑是饭点运力瞬间打满。11 点半到 12 点半,订单量可能是平峰的十倍,骑手接单上限很快到顶。系统要支持动态调整接单上限,高峰期临时把兼职骑手的上限从 2 提到 4,同时给未接单订单加价。第三个坑是骑手多是学生,上课时间离线。派单时不能只看当前位置,还要看骑手的历史在线时段。如果某骑手每天下午两点到四点离线,系统应该在这个时段降低他的派单优先级,而不是派了没人接再重派。6. 派单策略的进阶调优:用日志回放找到你的最优权重派单权重没有万能值,校园和同城不同,早高峰和晚高峰也不同。进阶做法是用派单日志表做回放:把过去一周的订单拿出来,用不同的权重组合重新跑一遍评分,统计每个组合下的平均接单时长、骑手空驶距离、订单完成率。选综合指标最好的那组作为当前权重,然后每周回放一次,随季节和骑手结构变化调整。回放脚本可以这样写,核心是把历史订单和骑手快照喂给评分函数:import json from itertools import product def replay(orders, riders_snapshot, weight_grid): best = None for w_dist, w_route, w_load, w_credit in weight_grid: weights = {'distance': w_dist, 'route': w_route, 'load': w_load, 'credit': w_credit} total_accept_time = 0 total_empty_dist = 0 for order in orders: candidates = [r for r in riders_snapshot[order['order_no']] if r['status'] == 'online'] if not candidates: continue scored = [(score_rider(order, r, weights)['total'], r) for r in candidates] scored.sort(key=lambda x: x[0], reverse=True) chosen = scored[0][1] total_accept_time += chosen['accept_time'] total_empty_dist += chosen['empty_distance'] avg_time = total_accept_time / max(1, len(orders)) avg_dist = total_empty_dist / max(1, len(orders)) if best is None or avg_time + avg_dist * 0.01 < best['score']: best = {'weights': weights, 'avg_time': avg_time, 'avg_dist': avg_dist, 'score': avg_time + avg_dist * 0.01} return best # 权重网格:距离0.2到0.5,顺路0.1到0.4,负载0.1到0.4,信用0.1到0.2 grid = [(d, r, l, c) for d, r, l, c in product( [0.2, 0.3, 0.4, 0.5], [0.1, 0.2, 0.3, 0.4], [0.1, 0.2, 0.3, 0.4], [0.1, 0.15, 0.2]) if abs(d + r + l + c - 1.0) < 0.001]逻辑说明:weight_grid 只保留权重和为 1 的组合,避免无效计算。评分函数复用线上同一套,保证回放结果和线上一致。综合指标里空驶距离乘 0.01 是把米换算成和分钟可比的量级,具体系数按你更在意时间还是成本来调。回放数据量大的话,可以按小时分段跑,避免内存爆掉。除了权重,还有两个进阶技巧。一是分时段权重,早中晚各一套,用定时任务切换。二是骑手分级,信用分高的骑手在同等条件下优先派单,但不要给高信用骑手无限派单,否则他会挑单,低信用骑手永远没单。我一般给高信用骑手每天前 10 单优先,之后回归正常评分。最后说一个我自己的习惯:每次调整派单策略,先在小范围灰度,比如只对某个楼栋或某个时段生效,观察两天再接单时长和投诉率,没问题再全量。派单策略直接影响骑手收入和用户体验,改错了就是两头挨骂。灰度期间派单日志要开全量,别省那点存储。希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一套面向同城跑腿与校园配送场景的全开源技术方案,基于Fastadmin、ThinkPHP与Uniapp开发,包含用户端、骑手端和运营后台三端,适合跑腿创业团队、外包开发者及需要私有化部署的运营方使用。系统支持帮取、帮送两种业务模式,并内置一键抢单、主动接单、自由开工、系统派单与智能派单等完整调度能力,可按骑手距离、送货地址与等级自动推送订单。计价层面覆盖按距离、重量计费,支持临时加价、预约取件、跑腿小费与物品保价,配合地图选点、距离计算与路线导航,形成从下单到履约的闭环。资源以zip压缩包形式提供,整体约64.82MB,源码无加密,便于二次开发与私有化部署。目前已有94人学习关注,适合希望快速搭建跑腿平台、研究派单算法与多端协同实现的技术人员参考。1. 跑腿小程序智能派单系统:从下单到骑手接单,中间到底发生了什么你在校园里点了一份外卖代取,或者在同城下了一单急送,从点击“下单”到骑手出现在你面前,中间可能只隔了几十秒。但就是这几十秒里,系统要做完一整套决策:订单该派给谁、为什么派给他、派错了怎么办。跑腿小程序智能派单系统要解决的核心问题,就是把这套决策从“人工调度拍脑袋”变成“算法自动算”。它适合两类人:一类是想做校园跑腿或同城配送产品的开发者,需要一套能跑通的用户端加骑手端闭环;另一类是已经在做配送系统、但派单还靠手动或简单轮询的团队,想升级成智能派单。全开源意味着你能看到订单流转、骑手状态、派单策略的完整链路,而不是只调一个黑匣子接口。这一章先把“智能派单”这四个字拆开,说清楚它到底在算什么。智能派单的本质是一个实时匹配问题。用户下单时带着取件地址、送达地址、时间要求、物品重量;骑手端持续上报位置、接单状态、当前负载。系统要在毫秒级内从在线骑手里选出一个“最合适”的,把订单推给他。这里的“合适”不是单一维度,而是距离、顺路程度、骑手当前单量、历史准时率、甚至骑手等级的综合评分。校园场景和同城场景的差别也在这里:校园里楼栋密集、骑手多是学生兼职、订单集中在饭点和快递高峰;同城场景距离拉长、路况复杂、骑手全职比例高。一套系统要同时覆盖,派单策略就必须可配置,而不是写死一个公式。全开源的价值在于,你能看到派单评分函数是怎么写的、骑手状态机是怎么流转的、订单超时后是怎么重新派单的。这些细节在闭源 SaaS 里通常被藏起来,出了问题只能等客服。而自己掌握源码,你可以根据校园的楼栋分布调整距离权重,根据兼职骑手的接单习惯调整推送时机。接下来的章节会从数据模型、派单算法、用户端和骑手端的实现、以及部署踩坑几个角度,把这套系统拆到能照着复现的程度。2. 派单系统的数据模型与状态机:订单和骑手怎么被描述2.1 订单状态机:从待派单到已完成要经过几个状态订单不是一张静态表,它有一条明确的状态流转路径。常见做法是定义六到八个状态:待支付、待派单、已派单、已接单、取件中、配送中、已送达、已完成,外加一个异常关闭。每个状态之间的跃迁都要有触发条件和超时兜底。比如“待派单”超过 30 秒没有骑手接单,就要触发重新派单或扩大派单范围;“已派单”超过 60 秒骑手未确认,订单要回退到待派单并给骑手记一次未响应。用数据库表来落地时,订单主表至少要有这些字段:订单号、用户 ID、骑手 ID、取件地址经纬度、送达地址经纬度、订单状态、派单次数、创建时间、派单时间、接单时间、取件时间、送达时间。派单次数这个字段很关键,它决定了系统要不要给订单加急或加价。下面是一个简化的建表语句,用 MySQL 语法:CREATE TABLE `delivery_order` ( `order_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单主键', `order_no` VARCHAR(32) NOT NULL COMMENT '对外订单号', `user_id` BIGINT NOT NULL COMMENT '下单用户ID', `rider_id` BIGINT DEFAULT NULL COMMENT '接单骑手ID,未派单为空', `pickup_lat` DECIMAL(10,7) NOT NULL COMMENT '取件纬度', `pickup_lng` DECIMAL(10,7) NOT NULL COMMENT '取件经度', `deliver_lat` DECIMAL(10,7) NOT NULL COMMENT '送达纬度', `deliver_lng` DECIMAL(10,7) NOT NULL COMMENT '送达经度', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待派单 1已派单 2已接单 3取件中 4配送中 5已送达 6已完成 9异常关闭', `dispatch_count` INT NOT NULL DEFAULT 0 COMMENT '被派单次数', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `dispatch_time` DATETIME DEFAULT NULL COMMENT '最近一次派单时间', `accept_time` DATETIME DEFAULT NULL COMMENT '骑手接单时间', `finish_time` DATETIME DEFAULT NULL COMMENT '送达时间', PRIMARY KEY (`order_id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status_dispatch` (`status`, `dispatch_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='配送订单主表';逻辑说明:status 用数字枚举而不是字符串,是为了在索引和范围查询时更快;dispatch_count 配合 dispatch_time 可以筛出“派了三次还没人接”的订单;pickup 和 deliver 的经纬度用 DECIMAL(10,7) 而不是 FLOAT,是因为 FLOAT 在距离计算时会有精度漂移,校园里楼栋之间只差几十米,精度不够会导致派错楼。参数上,经纬度精度 7 位大约对应 1 厘米,足够用;如果只做同城粗派,5 位也够,但没必要省这点空间。2.2 骑手状态与位置上报:在线、忙碌、接单上限怎么存骑手端要持续做两件事:上报位置、更新接单状态。骑手表里除了基本信息,关键字段是当前状态、当前接单数、最大接单数、最近一次位置和位置更新时间。当前状态一般分离线、在线空闲、在线忙碌、休息中。在线空闲才能被派单,在线忙碌表示手上有单但还能顺路带,休息中不派单。位置上报不建议直接写 MySQL,高频写入会把订单表拖慢。常见做法是骑手位置先写 Redis,用 GEO 结构存经纬度,键可以是 rider:geo,成员是骑手 ID。派单时用 GEORADIUS 或 GEOSEARCH 查附近骑手,再回表查骑手状态和负载。下面是一段用 Redis 命令模拟位置更新和附近查询的例子:# 更新骑手位置,经度在前纬度在后 GEOADD rider:geo 116.397128 39.916527 rider_1001 GEOADD rider:geo 116.398200 39.917100 rider_1002 # 查询取件点附近 3 公里内的骑手,按距离升序 GEOSEARCH rider:geo FROMLONLAT 116.397000 39.916000 BYRADIUS 3 km ASC WITHDIST逻辑说明:GEOADD 的经纬度顺序容易写反,Redis 要求先经度后纬度,写反了查出来是南极附近。GEOSEARCH 是 Redis 6.2 之后的命令,老版本用 GEORADIUS。BYRADIUS 3 km 表示半径三公里,校园场景可以缩到 1 公里,同城可以放到 5 公里。WITHDIST 会返回距离,方便在评分时直接用。注意 Redis GEO 只负责“谁在附近”,不负责“谁有空”,所以查出来之后还要拿骑手 ID 去查状态和当前单量,过滤掉离线、休息和已达接单上限的骑手。2.3 派单日志表:为什么每次派单都要留痕派单不能只改订单表就完事,每次派单都要写一条日志:订单号、候选骑手列表、最终选中的骑手、评分明细、派单时间、骑手响应结果。这张表在排查“为什么这单派给了他”时是唯一的后悔药。没有日志,用户投诉派单慢、骑手投诉派单不公平,你都拿不出证据。日志表可以这样设计:CREATE TABLE `dispatch_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `candidate_riders` JSON DEFAULT NULL COMMENT '候选骑手及评分快照', `chosen_rider_id` BIGINT DEFAULT NULL COMMENT '最终选中骑手', `score_detail` JSON DEFAULT NULL COMMENT '各维度得分', `dispatch_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `rider_response` TINYINT DEFAULT NULL COMMENT '1接单 2拒绝 3超时未响应', PRIMARY KEY (`id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='派单日志表';candidate_riders 和 score_detail 用 JSON 存,是因为候选骑手数量和评分维度会随策略调整而变化,用固定列会频繁改表。JSON 字段在 MySQL 5.7 以上支持,查询时可以用 JSON_EXTRACT 取特定骑手。代价是 JSON 列不能直接建普通索引,但派单日志主要是按订单号查,订单号有索引就够了。3. 智能派单算法:评分函数怎么写、参数怎么调3.1 派单评分的四个维度:距离、顺路、负载、信用智能派单的核心是一个评分函数,输入是订单和候选骑手,输出是一个分数,分数最高的骑手拿到订单。常见做法是加权求和:总分等于距离得分乘距离权重,加上顺路得分乘顺路权重,加上负载得分乘负载权重,加上信用得分乘信用权重。每个维度的得分都归一化到 0 到 100 之间,权重之和为 1。距离得分最简单:骑手当前位置到取件点的距离越近分越高。可以用一个衰减函数,比如距离 0 米得 100 分,距离 3 公里得 0 分,中间线性衰减。顺路得分要看骑手手上已有订单的送达点和新订单的取件点、送达点是否同方向。一个粗略但有效的算法是比较新订单送达点与骑手当前配送方向的夹角,夹角越小分越高。负载得分是骑手当前接单数越少分越高,但要注意,完全不给忙碌骑手派单会导致高峰期运力浪费,所以负载权重通常设得比距离低。信用得分来自历史数据:准时率、取消率、投诉率,新骑手给一个中性初始分。下面是一个 Python 版的评分函数示例,可以直接嵌到派单服务里:import math def haversine(lat1, lng1, lat2, lng2): """计算两点间距离,单位米""" R = 6371000 phi1, phi2 = math.radians(lat1), math.radians(lat2) dphi = math.radians(lat2 - lat1) dlambda = math.radians(lng2 - lng1) a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2 return 2 * R * math.asin(math.sqrt(a)) def score_rider(order, rider, weights): """ order: 含 pickup_lat/lng, deliver_lat/lng rider: 含 lat/lng, current_orders, max_orders, credit_score, current_direction weights: {'distance':0.4, 'route':0.25, 'load':0.2, 'credit':0.15} """ # 距离得分:0米100分,3000米0分 dist = haversine(rider['lat'], rider['lng'], order['pickup_lat'], order['pickup_lng']) dist_score = max(0, 100 - (dist / 3000) * 100) # 顺路得分:新订单送达方向与骑手当前方向夹角 order_dir = math.atan2(order['deliver_lat'] - order['pickup_lat'], order['deliver_lng'] - order['pickup_lng']) angle_diff = abs(order_dir - rider['current_direction']) angle_diff = min(angle_diff, 2 * math.pi - angle_diff) route_score = max(0, 100 - (angle_diff / math.pi) * 100) # 负载得分:接单越少分越高 load_ratio = rider['current_orders'] / max(1, rider['max_orders']) load_score = max(0, 100 - load_ratio * 100) # 信用得分:直接取历史信用分 credit_score = rider['credit_score'] total = (dist_score * weights['distance'] + route_score * weights['route'] + load_score * weights['load'] + credit_score * weights['credit']) return { 'total': round(total, 2), 'distance': round(dist_score, 2), 'route': round(route_score, 2), 'load': round(load_score, 2), 'credit': round(credit_score, 2) }逻辑说明:haversine 用球面距离公式,比直接算平面距离准,校园小范围其实平面距离也够,但同城场景必须用球面。距离得分里 3000 米是衰减半径,校园可以改成 1000,同城改成 5000。顺路得分用 atan2 算方向角,夹角用弧度表示,注意处理 2π 环绕。负载得分用当前单量除以最大单量,最大单量建议按骑手等级设,兼职骑手设 2 到 3,全职设 5 到 8。信用分初始给 80,准时加、取消扣,范围控制在 0 到 100。权重怎么调是玄学也是血泪经验。校园饭点高峰期,距离权重可以降到 0.3,负载权重提到 0.3,因为骑手都在楼栋附近,距离差别不大,谁手上单少谁跑得快。同城平峰期,距离权重提到 0.5,顺路权重 0.3,因为空跑成本高。调权重不要拍脑袋,用派单日志表回放历史订单,看不同权重下平均接单时长和骑手空驶距离的变化。3.2 派单触发时机:定时轮询还是事件驱动派单触发有两种常见模式。定时轮询是每隔几秒扫一次待派单订单,批量派。事件驱动是订单创建时立即触发派单,骑手拒单或超时也立即触发重派。校园场景订单集中爆发,纯事件驱动会在饭点瞬间打出大量派单请求,把骑手端推送打爆;纯轮询又会让订单等待时间变长。我一般用混合模式:订单创建后先做一次即时派单,如果 10 秒内没骑手接单,进入轮询队列,每 5 秒重派一次,同时逐步扩大搜索半径。重派时要注意,不要给同一个骑手反复推同一单。派单日志表里记下已经推过的骑手,重派时过滤掉。如果一单推了 5 个骑手都没接,系统应该自动加价或转人工调度。加价幅度可以按订单金额的 10% 递增,上限设 50%,避免成本失控。3.3 骑手拒单和超时未响应的处理骑手拒单是派单系统必须面对的现实。拒单原因很多:不顺路、手上有单、单纯不想跑。系统能做的不是阻止拒单,而是快速把订单转给下一个候选。拒单后要立即从候选列表里取下一个骑手推送,同时给拒单骑手记一次拒单次数,拒单率过高的骑手降低信用分或限制接单。超时未响应比拒单更麻烦,因为骑手可能只是没看手机。常见做法是给骑手端推送后启动一个 30 秒的定时器,30 秒内没点接单也没点拒绝,视为超时,订单回退重派,骑手记一次未响应。连续三次未响应的骑手,系统自动置为休息中,需要手动上线。这个逻辑要在服务端做,不能依赖骑手端上报,否则骑手端进程被杀就永远不触发。4. 用户端与骑手端的关键实现:下单、推送、接单、轨迹4.1 用户端下单接口:地址解析和费用预估用户端下单不是简单提交一个表单。用户输入取件地址和送达地址,系统要先把文字地址转成经纬度,再算距离,再根据距离和重量算配送费。地址解析可以调地图服务的 API,也可以让用户在地图上选点。校园场景建议做楼栋下拉,因为学生写地址很随意,“三号楼”“3 栋”“三舍”可能指同一个地方,纯文本解析容易翻车。费用预估的公式一般是起步价加距离费加重量费。起步价覆盖前 1 公里,超出部分每公里加价,重量超过 5 公斤加超重费。预估结果要返回给用户确认,确认后再创建订单。下面是一个下单接口的伪代码结构:def create_order(user_id, pickup_text, deliver_text, weight_kg): # 地址解析,失败则返回让用户手动选点 pickup = geocode(pickup_text) deliver = geocode(deliver_text) if not pickup or not deliver: return {'code': 4001, 'msg': '地址解析失败,请在地图上选点'} distance = haversine(pickup.lat, pickup.lng, deliver.lat, deliver.lng) base_fee = 3.0 # 起步价,覆盖1公里 per_km = 1.5 # 超出部分每公里 heavy_fee = max(0, weight_kg - 5) * 0.5 # 超重费 total_fee = base_fee + max(0, distance/1000 - 1) * per_km + heavy_fee order_no = gen_order_no() insert_order(order_no, user_id, pickup, deliver, total_fee) trigger_dispatch(order_no) # 触发即时派单 return {'code': 0, 'order_no': order_no, 'fee': round(total_fee, 2)}逻辑说明:geocode 返回 None 时不要硬猜,让用户手动选点比派错楼栋强。距离用米,费用计算时转公里。起步价和每公里价格要可配置,不同城市、不同校园可以不一样。trigger_dispatch 是异步调用,不要阻塞下单接口,否则用户会等派单结果才看到下单成功,体验很差。4.2 骑手端接单推送:长连接还是轮询骑手端要实时收到派单通知,技术上有两种选择:WebSocket 长连接和 HTTP 轮询。长连接实时性好,但骑手端在弱网环境下容易断,断了要重连,重连期间派单会丢。轮询实现简单,但频率高了耗电,频率低了延迟大。我一般用长连接加兜底轮询:骑手端保持 WebSocket,同时每 15 秒轮询一次待接单列表,防止长连接假死。推送内容不要只发订单号,要把取件地址、送达地址、距离、费用一起推,骑手才能快速判断接不接。推送格式用 JSON,字段名要短,减少流量。骑手端收到推送后弹窗加声音提醒,弹窗上直接放接单和拒绝按钮,减少操作步骤。4.3 配送轨迹上报与用户端展示骑手接单后要持续上报位置,用户端要能看到骑手到哪了。轨迹上报频率建议 5 到 10 秒一次,太频繁耗电,太稀疏轨迹跳变。位置存 Redis 的 GEO 结构,同时写一份到轨迹表做历史回放。用户端展示时,不要直接画折线,校园里楼栋间直线不可达,画折线会误导用户。常见做法是只显示骑手当前位置图标加预计到达时间,预计时间用直线距离除以平均速度再乘一个路况系数。轨迹表可以按天分表,避免单表过大。字段包括骑手 ID、订单号、经纬度、上报时间。查询时按订单号查,按时间排序。如果用户投诉骑手绕路,轨迹表就是证据。5. 部署与避坑:从本地跑通到线上稳定要过几道坎5.1 本地跑通的最小步骤拿到全开源包后,不要急着改代码,先按最小依赖跑通。常见步骤是:装 MySQL 和 Redis,导入建表 SQL,改配置文件里的数据库和 Redis 地址,启动后端服务,启动用户端和骑手端。如果包里有 Docker Compose 文件,优先用 Docker 起依赖,省去装环境的麻烦。跑通的标准是:用户端能下单,骑手端能收到推送并接单,订单状态能从待派单走到已完成。配置文件里最容易错的是 Redis 地址和端口,以及地图服务的 Key。地图 Key 通常有域名白名单,本地调试要加 127.0.0.1。如果派单一直不触发,先看后端日志里有没有“候选骑手为空”,再看 Redis 里 GEO 结构有没有数据,最后看骑手状态是不是在线空闲。5.2 派单延迟高、骑手收不到推送怎么排查派单延迟高,按这个顺序查:订单是否进入了待派单状态、派单服务是否在运行、Redis 里附近骑手查询是否超时、骑手端长连接是否在线。如果 Redis 查询慢,可能是 GEO 数据量太大,校园场景几千个骑手没问题,同城几十万骑手就要分片,按区域建多个 GEO 键。骑手收不到推送,先确认骑手状态是在线空闲,再确认推送通道是否正常。长连接断了骑手端要自动重连,重连失败要降级到轮询。如果骑手端在后台被系统杀掉,推送也收不到,这种情况只能靠轮询兜底。测试时可以用两个手机,一个下单一个接单,看推送延迟。5.3 订单状态不一致和重复派单订单状态不一致通常是因为并发操作。比如骑手 A 和骑手 B 同时点接单,两个请求都查到订单是已派单状态,都去更新骑手 ID,后更新的覆盖先更新的。解决办法是用数据库乐观锁:更新时带上前一个状态,UPDATE delivery_order SET rider_id=?, status=2 WHERE order_no=? AND status=1,影响行数为 0 说明被别人抢先了,直接返回接单失败。重复派单是另一个坑。定时轮询和事件驱动同时触发,同一订单可能被派两次。解决办法是派单前用 Redis 分布式锁锁住订单号,锁过期时间设 10 秒,派单完成后释放。锁的粒度要细,锁订单不锁整个派单服务,否则高峰期会排队。5.4 校园场景的三个特殊坑第一个坑是楼栋定位漂移。校园里楼栋密集,GPS 漂移几十米就可能派到隔壁楼。解决办法是让用户下单时从楼栋列表选,而不是纯靠定位。骑手端导航到楼栋门口,而不是经纬度点。第二个坑是饭点运力瞬间打满。11 点半到 12 点半,订单量可能是平峰的十倍,骑手接单上限很快到顶。系统要支持动态调整接单上限,高峰期临时把兼职骑手的上限从 2 提到 4,同时给未接单订单加价。第三个坑是骑手多是学生,上课时间离线。派单时不能只看当前位置,还要看骑手的历史在线时段。如果某骑手每天下午两点到四点离线,系统应该在这个时段降低他的派单优先级,而不是派了没人接再重派。6. 派单策略的进阶调优:用日志回放找到你的最优权重派单权重没有万能值,校园和同城不同,早高峰和晚高峰也不同。进阶做法是用派单日志表做回放:把过去一周的订单拿出来,用不同的权重组合重新跑一遍评分,统计每个组合下的平均接单时长、骑手空驶距离、订单完成率。选综合指标最好的那组作为当前权重,然后每周回放一次,随季节和骑手结构变化调整。回放脚本可以这样写,核心是把历史订单和骑手快照喂给评分函数:import json from itertools import product def replay(orders, riders_snapshot, weight_grid): best = None for w_dist, w_route, w_load, w_credit in weight_grid: weights = {'distance': w_dist, 'route': w_route, 'load': w_load, 'credit': w_credit} total_accept_time = 0 total_empty_dist = 0 for order in orders: candidates = [r for r in riders_snapshot[order['order_no']] if r['status'] == 'online'] if not candidates: continue scored = [(score_rider(order, r, weights)['total'], r) for r in candidates] scored.sort(key=lambda x: x[0], reverse=True) chosen = scored[0][1] total_accept_time += chosen['accept_time'] total_empty_dist += chosen['empty_distance'] avg_time = total_accept_time / max(1, len(orders)) avg_dist = total_empty_dist / max(1, len(orders)) if best is None or avg_time + avg_dist * 0.01 < best['score']: best = {'weights': weights, 'avg_time': avg_time, 'avg_dist': avg_dist, 'score': avg_time + avg_dist * 0.01} return best # 权重网格:距离0.2到0.5,顺路0.1到0.4,负载0.1到0.4,信用0.1到0.2 grid = [(d, r, l, c) for d, r, l, c in product( [0.2, 0.3, 0.4, 0.5], [0.1, 0.2, 0.3, 0.4], [0.1, 0.2, 0.3, 0.4], [0.1, 0.15, 0.2]) if abs(d + r + l + c - 1.0) < 0.001]逻辑说明:weight_grid 只保留权重和为 1 的组合,避免无效计算。评分函数复用线上同一套,保证回放结果和线上一致。综合指标里空驶距离乘 0.01 是把米换算成和分钟可比的量级,具体系数按你更在意时间还是成本来调。回放数据量大的话,可以按小时分段跑,避免内存爆掉。除了权重,还有两个进阶技巧。一是分时段权重,早中晚各一套,用定时任务切换。二是骑手分级,信用分高的骑手在同等条件下优先派单,但不要给高信用骑手无限派单,否则他会挑单,低信用骑手永远没单。我一般给高信用骑手每天前 10 单优先,之后回归正常评分。最后说一个我自己的习惯:每次调整派单策略,先在小范围灰度,比如只对某个楼栋或某个时段生效,观察两天再接单时长和投诉率,没问题再全量。派单策略直接影响骑手收入和用户体验,改错了就是两头挨骂。灰度期间派单日志要开全量,别省那点存储。希望帮到你。本文还有配套的精品资源,点击获取