简介:这是一套基于Fastadmin、ThinkPHP与Uniapp开发的全开源同城跑腿系统源码,面向跑腿创业团队、校园配送运营者及小程序开发者,覆盖用户端、骑手端与运营后台三端,可私有化部署,无加密限制。系统支持帮取帮送、预约取件、地图选点与导航规划,并内置按距离重量计价、临时加价、小费与物品保价等费用规则;派单侧提供一键抢单、语音弹窗提醒、接单大厅距离排序、自由开工,以及按骑手距离、送货地址与等级智能推送的系统派单模式,兼顾兼职与全职骑手佣金结算。资源包共2000个文件,以1079个js、265个html、235个vue、213个json为主,辅以142个md说明、58个css样式及sql建表脚本,压缩包约59.12MB,前后端与数据库结构完整。目前已有247人学习下载,适合需要快速搭建同城配送平台、研究派单算法与计价逻辑的开发者参考复用。
1. 跑腿小程序全开源:一套代码怎么同时撑起用户端和骑手端
校园里最常见的场景:晚上十点,宿舍楼下一堆人等着取快递,骑手在几个取件点之间来回跑,用户在小程序里刷新状态。这套「跑腿小程序 + 智能派单 + 同城配送」的组合,本质是把「下单—派单—取件—送达—结算」这条链路用一套后端串起来,前端拆成用户端和骑手端两个入口。全开源意味着你能拿到完整代码自己部署、改派单规则、接自己的支付和地图,而不是被某个 SaaS 按单抽成。
它适合三类人:想在学校或社区跑同城配送的小团队、需要给现有商户加跑腿能力的开发者、以及想拿一套真实业务系统练手全栈的人。核心难点不在页面,而在智能派单——订单怎么分配给骑手,直接决定履约成本和用户体验。下面从架构、派单算法、两端实现到踩坑,一层层拆开讲。
2. 先想清楚架构:用户端、骑手端和调度服务怎么分
2.1 三角色与数据流
一套跑腿系统最少有三个角色:用户(下单方)、骑手(履约方)、调度服务(撮合方)。用户端和骑手端通常都是小程序,共用一套后端 API,但权限和可见数据完全不同。用户端能看自己的订单和骑手位置,骑手端能看分配给自己的订单池和取送地址,调度服务在后台按规则把订单推给骑手。
数据流是这样的:用户下单写入订单表,状态为「待派单」;调度服务轮询或监听新订单,按派单策略选出候选骑手,推送通知;骑手接单后状态变「已接单」,开始取件;取件完成变「配送中」;送达后用户确认,状态「已完成」,触发结算。每一步状态变更都要落库并推送给相关方,否则用户端会出现「状态不刷新」的经典问题。
常见做法是订单表加一个status字段用整数枚举,配合一张order_status_log记录每次变更,方便排查「订单卡在某个状态」的玄学问题。
2.2 技术选型:为什么多数开源方案选小程序 + Node/Java 后端
跑腿业务的特点是「轻前端、重调度、强实时」。前端用微信小程序原生或 uni-app,原因是用户和骑手都习惯在微信里操作,不用装 App。后端主流是 Node.js(Express/NestJS)或 Java(Spring Boot),数据库 MySQL 存订单和用户,Redis 做骑手位置缓存和派单队列,地图用高德或腾讯的 Web 服务 API 算距离和路径。
选 Node 的理由是派单逻辑里大量 IO(查骑手、算距离、发通知),异步模型省事;选 Java 的理由是团队已有生态、事务控制更稳。两者都能跑,关键是 Redis 一定要上——骑手位置每秒都在变,直接压 MySQL 会拖垮订单查询。
提示:地图 API 的「骑行距离」和「直线距离」差别很大,派单算距离时用直线距离做初筛、骑行距离做精排,能省大量配额。
2.3 最小可运行骨架
下面是一个订单状态流转的核心表结构,MySQL 建表语句,字段注释写清了每个状态的用途:
CREATE TABLE `order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '下单用户', `rider_id` BIGINT DEFAULT NULL COMMENT '接单骑手,未派单为 NULL', `type` TINYINT NOT NULL COMMENT '1取件 2代买 3其他', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待派单 1已接单 2取件中 3配送中 4已完成 5已取消', `from_addr` VARCHAR(255) NOT NULL COMMENT '取件地址', `to_addr` VARCHAR(255) NOT NULL COMMENT '送达地址', `from_lng` DECIMAL(10,6) COMMENT '取件经度', `from_lat` DECIMAL(10,6) COMMENT '取件纬度', `to_lng` DECIMAL(10,6) COMMENT '送达经度', `to_lat` DECIMAL(10,6) COMMENT '送达纬度', `fee` INT NOT NULL COMMENT '配送费,单位分', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY `idx_status` (`status`), KEY `idx_rider` (`rider_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:status用整数而不是字符串,是为了派单查询时能走索引、比较快;经纬度单独存字段而不是只存地址文本,因为派单要算距离,解析地址文本太慢。fee用「分」存整数,避免浮点误差。idx_status让调度服务能快速捞出所有「待派单」订单,idx_rider让骑手端查自己的订单不扫全表。
参数怎么改:如果业务只做校园,经纬度精度可以降到 5 位;如果做全城,from_addr建议拆成「楼栋 + 详细」两列,方便骑手端导航。
3. 智能派单算法:从「抢单」到「系统派单」怎么落地
3.1 三种派单模式的取舍
跑腿系统常见的派单模式有三种:抢单、系统派单、混合。抢单是订单进公共池,骑手自己抢,实现最简单,但高峰期会出现「近单被抢光、远单没人接」。系统派单是后台按规则直接指派,履约率稳,但规则写不好会得罪骑手。混合模式是先系统派给最合适的骑手,N 秒不接再放公共池。
校园跑腿我一般推荐混合:午晚饭点单量集中,纯抢单会乱;纯系统派单骑手觉得不自由。混合模式的关键参数是「指派等待时长」,校园场景设 15 到 30 秒比较合适,太短骑手来不及看,太长用户等得急。
3.2 派单打分函数怎么写
系统派单的核心是一个打分函数:对每个在线骑手算一个分数,分高的优先。分数通常由三部分组成——距离、当前负载、历史履约。距离越近分越高,手上订单越多分越低,历史准时率高的加权。
def score_rider(rider, order, now): # 距离分:骑手到取件点的直线距离,越近越高,用 1/(1+d) 归一化 d = haversine(rider.lat, rider.lng, order.from_lat, order.from_lng) dist_score = 1.0 / (1.0 + d / 1000.0) # d 单位米,除以 1000 转公里 # 负载分:手上未完成订单越多,分越低 load_score = 1.0 / (1.0 + rider.active_orders) # 履约分:历史准时率,0~1 perf_score = rider.on_time_rate # 权重可按业务调,校园场景距离权重最高 return 0.5 * dist_score + 0.3 * load_score + 0.2 * perf_score逻辑说明:haversine算球面两点直线距离,比调地图 API 快得多,适合做初筛。三个分项都归一化到 0~1 再加权,避免量纲不同导致某一项压倒其他项。权重 0.5/0.3/0.2 是校园场景的经验值,距离最重要,因为校园范围小、骑手密度高,谁近谁去最划算。
参数怎么改:如果做的是全城配送,距离权重要降、负载权重升,因为远距离骑行时间长,让一个骑手顺路带多单更划算。active_orders建议设上限,比如超过 5 单就不再派新单,防止骑手爆单。
3.3 派单触发与并发控制
派单不能等用户下单后慢慢算,常见做法是订单入库后发一条消息到 Redis 队列,调度服务消费队列、算分、指派。这里有个必踩的坑:同一订单可能被两个调度实例同时处理,派给两个骑手。解决办法是用 Redis 的SETNX给订单加锁,锁的 key 是订单 ID,过期时间设 10 秒。
# 订单入库后投递到派单队列 redis-cli LPUSH dispatch_queue "order:10086" # 调度服务消费前先抢锁,抢到才处理 redis-cli SET dispatch_lock:10086 1 NX EX 10逻辑说明:LPUSH把订单 ID 推进队列,调度服务用BRPOP阻塞取,避免空轮询。SET NX EX是原子操作,只有第一个抢到的实例能拿到锁,处理完删掉锁。过期时间 10 秒是兜底,防止实例崩溃后锁不释放导致订单永远派不出去。
参数怎么改:队列积压严重时,可以起多个调度实例并行消费,锁机制保证不重复派单。锁过期时间要大于派单逻辑最长耗时,否则会出现「锁过期了第一个实例还在跑」的重复派单。
4. 用户端和骑手端:两端共用一个后端怎么不打架
4.1 接口权限隔离
用户端和骑手端共用后端,最容易出的问题是越权:骑手能查到别人订单,用户能改订单状态。做法是在 JWT 里带role字段,中间件按角色拦截。用户只能访问user_id等于自己的订单,骑手只能访问rider_id等于自己的订单,调度相关接口只对内部服务开放。
// Express 中间件:按角色和归属校验订单访问权限 function canAccessOrder(req, res, next) { const { role, uid } = req.user; // 从 JWT 解出 const order = await OrderModel.findById(req.params.id); if (!order) return res.status(404).json({ msg: '订单不存在' }); if (role === 'user' && order.user_id !== uid) { return res.status(403).json({ msg: '无权访问' }); } if (role === 'rider' && order.rider_id !== uid) { return res.status(403).json({ msg: '无权访问' }); } req.order = order; next(); }逻辑说明:先查订单再比对归属,比在 SQL 里拼WHERE user_id = ?更灵活,因为骑手端有些接口要查「待抢单」这种不属于自己的订单。req.order挂到请求上,后续处理函数不用重复查库。
参数怎么改:如果要做「骑手查看附近待抢单」,需要单独开一个接口,只返回脱敏后的地址和距离,不返回用户手机号。
4.2 骑手位置上报与用户端展示
用户端要看到骑手实时位置,靠的是骑手端定时上报经纬度,后端写 Redis,用户端轮询或走 WebSocket。小程序后台定位有频率限制,常见做法是骑手端在「配送中」状态每 10 到 15 秒上报一次,Redis 里存rider:location:{rider_id},设 60 秒过期,防止骑手下线后位置还显示。
# 骑手端上报位置,后端写入 Redis def report_location(rider_id, lng, lat): key = f"rider:location:{rider_id}" redis.hset(key, mapping={"lng": lng, "lat": lat, "ts": time.time()}) redis.expire(key, 60) # 60 秒不更新视为离线逻辑说明:用 Hash 存经纬度和时间戳,方便一次取出。过期时间 60 秒是「心跳」机制,骑手端崩溃或断网后位置自动失效,用户端就不会看到一个不动的假位置。
参数怎么改:校园场景范围小,上报间隔可以放宽到 20 秒省电;全城配送建议 10 秒,否则用户端地图上骑手会「跳格」。
4.3 订单状态推送
状态变更要主动推给两端,否则用户会一直手动刷新。小程序里常用订阅消息,骑手端可以用 WebSocket 长连接。关键点是推送失败要有兜底:订阅消息用户可能没授权,那就退化成用户端轮询。
注意:订阅消息是「一次性」的,用户授权一次只能推一条,要在下单、接单、送达等关键节点分别引导授权,别指望一次授权推全程。
5. 避坑与排查:跑腿系统上线后最容易翻车的五件事
5.1 订单卡在「待派单」不动
现象:用户下单后状态一直不变,骑手端也看不到单。原因通常是调度服务没消费队列,或者派单锁没释放导致后续订单全被跳过。排查先看 Redis 队列长度LLEN dispatch_queue,如果一直涨说明消费者挂了;再看有没有残留的dispatch_lock:*键。解决是给调度服务加健康检查,锁的过期时间设合理,消费失败要重新入队而不是丢弃。
5.2 同一订单派给两个骑手
现象:两个骑手同时收到同一单,都去取件。原因是派单没加锁或锁过期太短,两个调度实例并发处理。解决是用SET NX EX抢锁,锁过期时间大于派单逻辑最长耗时,指派成功后立即把订单状态改成「已接单」,用数据库行锁兜底。
5.3 骑手位置在地图上乱跳
现象:用户端看到骑手位置忽远忽近。原因是骑手端上报的经纬度坐标系不统一,高德用 GCJ-02,GPS 原始是 WGS-84,混用会偏移几百米。解决是统一坐标系,上报前转换,后端存一种坐标系,前端展示时再按地图 SDK 要求转换。
5.4 高峰期派单越来越慢
现象:单量一上来,派单延迟从几百毫秒涨到几秒。原因是每次派单都全量查在线骑手并逐个算距离,骑手多了就慢。解决是用 Redis GEO 存骑手位置,先按半径筛出附近骑手再算分,把候选集从几百降到几十。
5.5 用户确认收货后钱没到骑手账
现象:订单完成了,骑手余额没变。原因是结算逻辑和状态变更没放在同一个事务里,状态改了但结算失败。解决是把「改状态 + 写结算流水 + 更新余额」放一个数据库事务,或者用消息队列保证最终一致,结算失败要能重试。
6. 派单权重怎么调:一个可复现的调参方法
派单权重没有标准答案,得按自己的业务数据调。我一般用一个离线回放的方法:把过去一周的真实订单和骑手轨迹导出来,用不同权重跑一遍派单模拟,看平均接单时长和骑手空驶距离两个指标。
具体做法是写一个模拟脚本,读历史订单,对每个订单用当前权重算一遍该派给谁,记录「从下单到骑手接单」的模拟时长,以及骑手从当前位置到取件点的距离。跑几组权重对比:
| 权重组合(距离/负载/履约) | 平均接单时长 | 平均空驶距离 |
|---|---|---|
| 0.5 / 0.3 / 0.2 | 42 秒 | 380 米 |
| 0.7 / 0.2 / 0.1 | 35 秒 | 290 米 |
| 0.3 / 0.5 / 0.2 | 58 秒 | 520 米 |
从这组模拟数据能看出,距离权重越高,接单越快、空驶越短,但骑手负载会不均,热门骑手容易爆单。校园场景我最后定在 0.6/0.25/0.15 附近,兼顾速度和公平。调参时别只看平均值,要看 P95——极端慢的单才是用户投诉的来源。
验证方法上,上线后加一个埋点:每单记录「派单耗时」和「骑手接单耗时」,按小时聚合。如果某个时段派单耗时突然涨,多半是骑手在线数不够或队列积压,这时候该做的是引导骑手在线,而不是继续调权重。
这套系统我踩过最深的一个坑,是早期没做派单锁,测试环境单实例跑得好好的,一上生产双实例就重复派单,两个骑手跑到同一个取件点面面相觑。后来养成习惯:任何涉及「抢」的逻辑,先想并发,再写代码。希望帮到你。
本文还有配套的精品资源,点击获取