简介:这份开源资源是面向同城跑腿与校园配送创业团队、外包开发者及技术学习者的完整系统源码,基于Fastadmin+ThinkPHP与Uniapp构建,包含用户端、骑手端和运营后台三端,可私有化部署且无加密,适合二次开发或直接落地运营。压缩包为zip格式,整体约64.82MB,上游未提供文件总数与类型明细,但按项目结构可预期涵盖后端接口、前端页面、数据库脚本与配置说明等模块。功能上覆盖按距离重量计价、夜间天气临时加价、预约取件、跑腿小费、物品保价、地图选点导航,以及一键抢单、主动接单、自由开工、系统派单与智能派单等完整调度链路,并区分兼职与全职骑手佣金模式。目前已有94人学习下载,读者可据此快速理解同城配送的订单流转、骑手调度与计价逻辑,获得一套可直接部署验证的技术方案,用于搭建跑腿平台或作为毕业设计、课程项目的参考实现。
1. 跑腿小程序智能派单系统:同城配送里最容易被低估的一环
很多人第一次接触跑腿小程序智能派单系统,注意力都放在「用户端下单页面好不好看」上,结果上线后才发现,真正决定生死的是派单那几秒钟。校园跑腿预约取件这种场景,订单密度高、距离短、骑手流动性大,如果还靠人工在群里喊单或者按顺序轮询,午高峰十分钟就能积压几十单,用户端显示「已接单」但骑手根本没动,投诉立刻上来。这套用户端加骑手端全开源的方案,核心价值不在界面,而在于把「谁去送、怎么送、送完怎么结算」这条链路用代码固定下来。它适合两类人:一是想快速搭一套校园或同城配送 Demo 的开发者,二是手里有骑手资源、想用技术手段把调度效率提上去的小团队。下面我按自己踩过的坑,把派单逻辑、两端联调和参数调优拆开讲。
2. 智能派单的算法选型:为什么不是简单的「就近分配」
2.1 从「抢单」到「派单」的决策分界线
早期校园跑腿系统大多用抢单模式,骑手端刷新列表,谁手快谁拿。这个模式在订单少的时候没问题,但同城配送一旦单量上来,抢单会暴露三个硬伤:骑手挑肥拣瘦,远单没人接;热门骑手手里压十几单,新人一单没有;用户端等待时间不可控。智能派单的本质是把「骑手主动选择」变成「系统主动匹配」,匹配依据不能只看直线距离,还要算骑手当前负载、历史准时率、路线顺路程度。
我一般会把派单策略分成两层:第一层是硬性过滤,比如骑手是否在营业范围、是否已接单超过上限、当前状态是否可接单;第二层是打分排序,对通过过滤的骑手算一个综合分,取最高分派单。硬性过滤用数据库查询就能做,打分排序才是算法真正要写的地方。常见做法是用加权求和:score = w1 * 距离分 + w2 * 负载分 + w3 * 顺路分,权重根据业务阶段调。校园场景距离短,距离分权重可以降到 0.4,负载分提到 0.4,顺路分 0.2,避免一个骑手被派太多单导致超时。
2.2 距离分、负载分、顺路分的计算方式
距离分不是简单的「越近越高」,因为骑手到取件点和取件点到送达点是两段路。我通常用骑手当前位置到取件点的骑行距离做基础,再叠加取件点到送达点的距离,取一个综合里程。如果系统里接了路径规划服务,用实际骑行距离;没有的话用 Haversine 公式算直线距离再乘一个 1.3 到 1.5 的系数,校园内道路规整,1.3 够用。
负载分要结合骑手当前未完成订单数和订单预估耗时。一个骑手手里已经有 3 单,每单预计 15 分钟,那他的负载就是 45 分钟。新订单预计 20 分钟,如果派给他,总负载变成 65 分钟,超过设定的 60 分钟阈值就该降权。负载分的计算可以写成load_score = max(0, 1 - current_load / max_load),max_load 按骑手类型设,全职骑手可以设 5 单,兼职设 3 单。
顺路分是提升效率的关键。如果新订单的取件点和送达点,与骑手现有订单的路线方向夹角小于 30 度,且新订单送达点在现有路线延长线上,顺路分给满分。实现时可以用向量点积算夹角,再判断投影是否在前进方向。这部分代码不复杂,但能明显减少骑手空驶。
2.3 一个可复现的打分函数实现
下面这段 Python 代码是我在模拟项目里用的打分逻辑,去掉了业务耦合,可以直接改成你需要的语言。它接收骑手列表和订单信息,返回排序后的骑手 ID。
import math def haversine(lat1, lng1, lat2, lng2): # 地球半径,单位公里 R = 6371.0 dlat = math.radians(lat2 - lat1) dlng = math.radians(lng2 - lng1) a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlng/2)**2 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) return R * c def score_rider(rider, order, weights): # 距离分:骑手到取件点 + 取件点到送达点,直线距离乘系数 d1 = haversine(rider['lat'], rider['lng'], order['pickup_lat'], order['pickup_lng']) d2 = haversine(order['pickup_lat'], order['pickup_lng'], order['drop_lat'], order['drop_lng']) total_km = (d1 + d2) * 1.3 # 距离越短分越高,用 5 公里做归一化上限 dist_score = max(0, 1 - total_km / 5.0) # 负载分:当前负载时长 / 最大可接时长 current_load = rider.get('current_load_minutes', 0) max_load = rider.get('max_load_minutes', 60) load_score = max(0, 1 - current_load / max_load) # 顺路分:简化处理,如果骑手已有订单方向与新订单方向夹角小,给高分 # 实际项目里需要遍历骑手现有订单算向量夹角,这里用占位值 route_score = rider.get('route_match_score', 0.5) final = (weights['dist'] * dist_score + weights['load'] * load_score + weights['route'] * route_score) return final def dispatch(order, riders, weights={'dist': 0.4, 'load': 0.4, 'route': 0.2}): # 硬性过滤:在线、可接单、未超负载上限 candidates = [r for r in riders if r['online'] and r['accepting'] and r.get('current_load_minutes', 0) < r.get('max_load_minutes', 60)] if not candidates: return None scored = [(r['id'], score_rider(r, order, weights)) for r in candidates] scored.sort(key=lambda x: x[1], reverse=True) return scored[0][0] # 返回最高分骑手 ID这段代码里weights字典是调参入口,校园午高峰可以把load提到 0.5,dist降到 0.3,防止骑手爆单。max_load_minutes建议按骑手历史平均完成时长动态设,不要写死 60。route_match_score在真实项目里要替换成向量计算,这里留了接口。注意haversine返回的是公里,如果你的坐标是 GCJ-02 或 BD-09,要先统一坐标系,否则距离会偏几百米,派单直接翻车。
3. 用户端与骑手端的数据契约:订单状态机怎么定
3.1 订单状态流转的五个关键节点
两端联调最容易出问题的地方不是接口通不通,而是状态不一致。用户端显示「骑手已取件」,骑手端还是「待取件」,这种玄学问题多半是状态机没定死。我一般把订单状态压缩成五个:待接单、已接单、已取件、配送中、已完成。取消和异常单独走关闭状态,不混在主流程里。
每个状态变更必须由服务端统一裁决,客户端只发请求,不自己改本地状态。比如骑手点「确认取件」,服务端校验当前状态是不是「已接单」,是才改成「已取件」,同时给用户端推一条消息。如果骑手端因为网络延迟重复提交,服务端要做幂等,用订单 ID 加状态做唯一键,重复请求直接返回成功但不重复变更。
状态流转的触发条件要写进数据库约束或者服务层校验,不能只靠前端按钮置灰。我见过一个项目,骑手端按钮没置灰,骑手连点两次「确认送达」,订单直接完成两次,结算多算了一笔。后来在服务层加了UPDATE orders SET status = 'completed' WHERE id = ? AND status = 'delivering',影响行数为 0 就拒绝,才堵住。
3.2 接口字段设计与推送时机
用户端和骑手端的接口字段要尽量复用,减少两端理解偏差。订单核心字段包括:订单 ID、取件地址经纬度、送达地址经纬度、物品描述、预估重量、期望送达时间、当前状态、骑手 ID、骑手位置。骑手端多一个「接单距离」和「预估收入」,用户端多一个「骑手实时位置」和「预计到达时间」。
推送时机比字段更重要。骑手接单后,用户端要立刻收到「已接单」推送,同时开始展示骑手位置。骑手取件后,用户端收到「已取件」,预计到达时间要重新算。骑手位置更新频率建议 5 到 10 秒一次,太频繁耗电,太慢用户觉得卡。校园场景 10 秒够用,同城配送可以放到 5 秒。
推送通道用 WebSocket 还是轮询,看你的部署环境。小规模用轮询也能跑,但骑手端在后台时轮询会被系统限制,WebSocket 更稳。如果不想维护长连接,可以用第三方推送服务,但要注意隐私合规,别把用户手机号明文推。
3.3 一个最小可用的状态变更接口示例
下面用 Node.js 写一个状态变更接口,展示服务端如何做幂等和状态校验。数据库用 MySQL,表结构省略,只关注逻辑。
const express = require('express'); const app = express(); app.use(express.json()); // 订单状态枚举 const STATUS = { PENDING: 'pending', ACCEPTED: 'accepted', PICKED: 'picked', DELIVERING: 'delivering', COMPLETED: 'completed' }; // 允许的状态流转:当前状态 -> 可变更到的状态 const TRANSITIONS = { [STATUS.PENDING]: [STATUS.ACCEPTED], [STATUS.ACCEPTED]: [STATUS.PICKED], [STATUS.PICKED]: [STATUS.DELIVERING], [STATUS.DELIVERING]: [STATUS.COMPLETED] }; app.post('/order/:id/status', async (req, res) => { const orderId = req.params.id; const { targetStatus, riderId } = req.body; // 查询当前订单,加行锁防止并发 const [rows] = await db.query('SELECT status, rider_id FROM orders WHERE id = ? FOR UPDATE', [orderId]); if (rows.length === 0) { return res.status(404).json({ error: '订单不存在' }); } const current = rows[0].status; // 校验流转是否合法 if (!TRANSITIONS[current] || !TRANSITIONS[current].includes(targetStatus)) { return res.status(400).json({ error: `不允许从 ${current} 变更为 ${targetStatus}` }); } // 骑手身份校验:只有接单骑手能操作 if (rows[0].rider_id && rows[0].rider_id !== riderId) { return res.status(403).json({ error: '无权操作该订单' }); } // 执行更新,带状态条件保证幂等 const [result] = await db.query( 'UPDATE orders SET status = ?, updated_at = NOW() WHERE id = ? AND status = ?', [targetStatus, orderId, current] ); if (result.affectedRows === 0) { // 并发情况下已被其他请求改掉,返回当前状态 return res.status(409).json({ error: '状态已变更,请刷新' }); } // 推送通知给用户端(伪代码) await pushToUser(orderId, { status: targetStatus, riderId }); res.json({ ok: true, status: targetStatus }); }); app.listen(3000, () => console.log('派单服务已启动'));这段代码的关键在FOR UPDATE行锁和UPDATE ... WHERE status = ?双重保险。TRANSITIONS表把合法流转写死,新增状态时改这里就行。pushToUser是伪代码,实际项目里要接 WebSocket 或推送服务。注意rider_id校验要在状态校验之后,否则骑手可能通过构造请求改别人订单。数据库连接池要设合理,派单高峰期并发更新多,连接不够会排队。
4. 派单系统的避坑与排查:那些上线后才暴露的问题
4.1 骑手位置漂移导致派单距离算错
现象:骑手明明在取件点附近,系统却派了 3 公里外的单,骑手端显示「距离 2.8 公里」,实际只有 500 米。原因多半是定位坐标系不统一。用户端下单用微信小程序拿到的经纬度是 GCJ-02,骑手端如果用高德或百度 SDK,返回的可能是 GCJ-02 或 BD-09,服务端如果直接混算,距离偏差能到几百米甚至公里级。解决方式是在服务端入口统一转成一种坐标系,我一般用 GCJ-02 做基准,百度坐标进来先转 GCJ-02 再入库。转换函数网上有成熟实现,别自己推。
4.2 并发派单导致一单多派
现象:同一个订单,两个骑手同时收到派单通知,都点了接单,最后两个人都去取件。原因是派单逻辑先查候选骑手,再逐个发通知,没有做原子占位。解决方式是在派单时用 Redis 分布式锁或者数据库唯一约束,订单 ID 加骑手 ID 做唯一键,谁先插入谁赢。更稳的做法是派单前先UPDATE orders SET dispatch_status = 'dispatching' WHERE id = ? AND dispatch_status = 'pending',影响行数为 1 才继续,否则说明已被其他线程派单。
4.3 骑手端后台被杀导致状态不同步
现象:骑手接了单,切到后台导航,再切回来发现订单列表空了,用户端却显示「已接单」。原因是骑手端本地状态没持久化,App 被系统回收后重新拉取列表,服务端返回的订单状态和本地缓存不一致。解决方式是在骑手端每次进入前台时强制拉一次进行中订单,本地缓存只做展示,不做状态判断。另外接单成功后要把订单 ID 写进本地数据库,App 重启后先读本地再和服务端对账。
4.4 预估送达时间越算越离谱
现象:用户端显示「预计 10 分钟送达」,过了 20 分钟还是「预计 10 分钟」。原因是预估时间只在接单时算了一次,骑手取件、等电梯、找楼栋这些耗时没动态更新。解决方式是在骑手状态变更时重新计算 ETA,用骑手当前位置到送达点的实时距离除以历史平均速度,再加上一个缓冲值。缓冲值按场景设,校园 3 分钟,写字楼 8 分钟。如果骑手位置超过 2 分钟没更新,ETA 要标记为「暂时无法预估」,别硬撑。
4.5 派单权重调完后骑手集体摆烂
现象:把距离权重调高后,近单被秒抢,远单没人接,骑手在线但拒绝派单。原因是权重只考虑了效率,没考虑骑手收益公平性。远单耗时长、收入没高多少,骑手自然用脚投票。解决方式是在打分函数里加一个「收益补偿分」,远单或者重物单给额外权重,或者用轮转机制,连续接远单的骑手下一单优先派近单。参数上可以设一个fairness_bonus,根据骑手最近 10 单的平均距离动态调整。
5. 把派单系统跑起来的进阶技巧:从能用到好用
5.1 用模拟数据压测派单接口
上线前一定要压测,别等真实订单进来才发现瓶颈。我一般写一个脚本,生成 500 个骑手和 2000 个订单,随机分布在校园地图上,然后并发调用派单接口,看响应时间和成功率。压测时重点观察数据库连接池和 Redis 锁竞争。如果响应时间超过 200 毫秒,先看是不是FOR UPDATE锁等待太久,可以考虑把行锁改成乐观锁,用版本号字段。
import requests import random import threading BASE_URL = 'http://localhost:3000' def create_order(i): payload = { 'pickup_lat': 39.9 + random.uniform(-0.01, 0.01), 'pickup_lng': 116.4 + random.uniform(-0.01, 0.01), 'drop_lat': 39.9 + random.uniform(-0.01, 0.01), 'drop_lng': 116.4 + random.uniform(-0.01, 0.01), 'weight': random.choice([1, 2, 5]) } r = requests.post(f'{BASE_URL}/order', json=payload) return r.json().get('order_id') def dispatch_order(order_id): r = requests.post(f'{BASE_URL}/dispatch', json={'order_id': order_id}) return r.status_code # 并发压测 orders = [create_order(i) for i in range(200)] threads = [] for oid in orders: t = threading.Thread(target=dispatch_order, args=(oid,)) threads.append(t) t.start() for t in threads: t.join() print('压测完成')这个脚本用线程模拟并发,实际压测可以用 Locust 或 wrk。注意create_order和dispatch_order要分开测,先造数据再压派单,否则数据库写入会成为瓶颈。压测结果里如果出现大量 409,说明并发冲突多,要检查锁粒度。
5.2 派单日志与回放:出问题时的后悔药
派单逻辑一旦上线,出问题很难复现,因为骑手位置、订单状态、权重参数都在变。我习惯在派单时把输入和输出完整记一条日志:订单 ID、候选骑手列表、每个骑手的各项分数、最终派给谁、耗时多少。日志写进独立的表或者消息队列,保留 7 天。出问题时用订单 ID 查日志,能还原当时的决策过程。如果发现是权重问题,可以拿历史数据回放,调整权重后重新跑一遍,看派单结果变化。这个习惯帮我省了很多扯皮时间。
5.3 灰度发布与权重热更新
派单权重不要写死在代码里,放到配置中心或者数据库,支持热更新。新权重先灰度 10% 的订单,观察骑手接单率和用户投诉率,没问题再全量。灰度期间要对比两组数据:平均派单时长、骑手负载均衡度、订单完成率。如果新权重导致某个指标恶化超过 5%,自动回滚。实现上可以用一个config表,派单服务每隔 30 秒拉一次,或者用 Redis 发布订阅实时推。
5.4 一个我踩过的坑:别在派单线程里做耗时操作
早期我在派单函数里直接调了第三方路径规划接口,结果接口超时导致派单线程阻塞,整个队列卡死。后来改成异步:派单先用直线距离算分,派单成功后再异步调路径规划更新 ETA。派单主流程只做内存计算和数据库写入,任何网络调用都扔到消息队列。这个改动让派单平均耗时从 800 毫秒降到 50 毫秒。血泪经验是,派单是高频操作,主链路越短越好,能异步的都异步。
5.5 验证派单效果的两个硬指标
怎么判断派单系统好不好用,别只看感觉。我盯两个指标:一是骑手接单后的平均空驶距离,这个值应该随着派单优化逐步下降,校园场景控制在 500 米以内算合格;二是订单从待接单到已接单的平均时长,午高峰不超过 30 秒,平峰不超过 10 秒。如果这两个指标没达标,先查权重和候选骑手数量,再看是不是骑手在线率太低。参数调优没有终点,我一般每周看一次数据,微调权重,保持手感。希望帮到你。
本文还有配套的精品资源,点击获取