简介这套全开源跑腿小程序系统基于FastAdmin、ThinkPHP与uniapp开发覆盖同城配送、校园跑腿、预约取件等常见场景适合需要快速搭建跑腿业务或进行私有化部署的团队使用。系统完整提供用户端、骑手端与运营后台支持智能派单、系统派单、一键接单和抢单可有效提升订单流转与骑手调度效率。资源包共2000个文件以JavaScript、HTML、Vue、JSON及Markdown文档为主涵盖前端页面、业务逻辑、接口配置与说明文档压缩包大小约43.26MB。整体源码无加密便于二次开发与定制部署。已有646人学习下载适合具备PHP和Vue基础的开发者用来落地跑腿项目或研究完整的同城配送技术方案。1. 全开源跑腿小程序系统的价值不在代码而在调度模型跑腿系统表面上是“用户下单、骑手接单”两层结构真正拉开差距的是中间的派单引擎。多数人拿到全开源跑腿小程序系统源码后第一反应是改界面、改支付结果上线后订单一多就乱骑手集中在同一片区、远单无人接、预约取件超时没人管。这些问题的根源不是前端性能而是“订单该分给谁”这件事没有算法支撑。同城配送、校园跑腿这类场景有个共同特点订单密度低、时效要求高、骑手体量小。大厂的全局最优派单在这里跑不动一套基于评分排序的智能派单规则反而更实用。这也是为什么“系统派单”会比“手动指派”更值得研究——它把调度员的经验变成可配置的参数让接单从碰运气变成可预期。这篇文章围绕一套典型的全开源跑腿小程序系统展开用户端负责下单、支付、预约取件骑手端负责接单、取货、送达后端把订单和骑手连接起来。读完可以回答三个问题智能派单的算法怎么写、全链路代码怎么在本地跑通、上线后用什么指标判断系统是否健康。2. 智能派单的核心逻辑订单分给谁比接单快慢更关键2.1 先分清三种派单模式抢单、顺序派单、智能派单系统的派单模块一般有三种工作模式。抢单模式最简单新订单广播给附近骑手先到先得。这种模式在校园跑腿里最常见好处是骑手主观能动性强坏处是高峰时段骑手会挑肥拣瘦低价短单经常无人响应。顺序派单是按照骑手的活跃时间轮流分配不考虑距离和负载适合订单路径高度规律的场景。全开源跑腿小程序系统里真正值得改的是第三种智能派单。它的思路是把“距离近”当作必要条件而非唯一条件综合骑手负载、距离、时效、服务质量多项指标算出一个分数订单自动推给最高分骑手。用户视角的差别 抢单模式 - 订单挂出去等骑手抢高峰期等 系统派单 - 订单直接找骑手骑手确认高峰期推提示如果源码里针对某个订单同时出现“待接单”和“系统派单中”两个状态说明系统支持抢单与派单混合模式一般通过订单类型字段区分。2.2 智能派单评分函数距离、负载、准时率、时效窗口智能派单的实质是对每个可用骑手计算一个综合分然后取最大值。一个通用评分函数可以表示为score w1 * distance_score w2 * load_score w3 * reliability_score w4 * urgency_scoredistance_score骑手当前位置与取件点的距离归一化值越近分数越高load_score骑手当前待配送订单数越少分数越高reliability_score骑手历史准时完成率衡量服务质量urgency_score订单时效紧迫度预约取件的单子会显著提升这个值权重系数 w1 到 w4 不是拍脑袋定的。校园跑腿场景里距离权重通常最高占 0.4 左右同城配送时效权重可以调到 0.3负载权重是为了防止热门骑手被塞爆0.2 比较合适服务质量权重初始设为 0.1跑两周后根据投诉率再调整。参数表参数默认值调节方向DISTANCE_WEIGHT0.4单量稀疏、骑手少时加大LOAD_WEIGHT0.2骑手抱怨单过多时加大RELIABILITY_WEIGHT0.1投诉率高时加大URGENCY_WEIGHT0.3预约单频繁超时时加大MAX_DISPATCH_DISTANCE3km超出范围直接过滤不参与评分还有一个容易被忽略的点距离计算不要用直线距离。城市道路环境下直线距离与实际骑行距离能差出 40%骑手端看到的“2 公里”实际要骑 2.8 公里接单意愿会明显下降。开源系统通常在配置文件中提供距离算法切换项可选直线距离和高德地图骑行距离。对外的展示、计费、派单计算应该统一使用真实骑行距离否则用户端显示的配送费会和骑手体验脱节。2.3 用最小代码实现一个可用的派单调度核心理解了评分逻辑后派单核心代码并不复杂。下面是一个可以放进跑腿系统后端服务的派单实现基于 TypeScript 编写interface Rider { id: string; lat: number; lng: number; activeOrders: number; onTimeRate: number; // 历史准时率 0~1 } interface DispatchOrder { id: string; pickupLat: number; pickupLng: number; expectedPickupTime: number; // 期望取件时间戳 urgent: boolean; } type Config { distanceWeight: number; loadWeight: number; reliabilityWeight: number; urgencyWeight: number; maxDispatchDistance: number; // 米 }; function computeDistance( lat1: number, lng1: number, lat2: number, lng2: number, realRoadFactor 1.4 ): number { // 简化球面距离计算生产环境建议调用地图服务骑行距离 const R 6371000; const dLat ((lat2 - lat1) * Math.PI) / 180; const dLng ((lng2 - lng1) * Math.PI) / 180; const a Math.sin(dLat / 2) ** 2 Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2; const distance 2 * R * Math.asin(Math.sqrt(a)); return Math.round(distance * realRoadFactor); } export function dispatchOrder( order: DispatchOrder, riders: Rider[], cfg: Config ): Rider | null { let bestRider: Rider | null null; let bestScore -Infinity; for (const rider of riders) { const distance computeDistance( rider.lat, rider.lng, order.pickupLat, order.pickupLng ); if (distance cfg.maxDispatchDistance) continue; const distanceScore 1 - distance / cfg.maxDispatchDistance; const loadScore 1 / (1 rider.activeOrders); // 订单越少分数越高 const reliabilityScore rider.onTimeRate; const timeWindowMs order.expectedPickupTime - Date.now(); const urgencyScore order.urgent ? timeWindowMs 15 * 60 * 1000 ? 1 : 0.5 : 0.3; const score cfg.distanceWeight * distanceScore cfg.loadWeight * loadScore cfg.reliabilityWeight * reliabilityScore cfg.urgencyWeight * urgencyScore; if (score bestScore) { bestScore score; bestRider rider; } } return bestRider; }逻辑说明先做距离过滤超出最大派单距离的骑手直接跳过避免远距离骑手因其他维度得分高而被选中。distanceScore 采用线性归一化距离为 0 时取 1达到上限时取 0。loadScore 使用 1/(1n) 形式骑手已有订单从 0 变 1 时分数从 1 降到 0.5衰减最快符合直觉。urgent 订单且距期望取件时间不足 15 分钟时单项得分拉满配合较高的 urgencyWeight 可以保证预约取件单优先派给附近骑手。maxDispatchDistance 的单位是米校园场景建议设在 2000 到 3000 之间同城配送可以放宽到 5000。实际工程里这个循环会进一步优化Rider 数量超过 50 人时建议把骑手按地理位置建立网格索引先粗筛出订单周围 3 公里内的骑手再进入评分循环这样接口耗时能控制在 50ms 以内。2.4 派单失败回退从自动指派到广播抢单智能派单不是每单都能成功。如果所有骑手都超出最大派单距离或者候选骑手全部处于忙碌状态系统需要一套降级策略。最常见的设计是自动派单超时 30 秒无骑手确认订单自动转为广播抢单推送给更大范围的骑手。这一层逻辑通常由定时任务驱动。放一个简单的降级规则表阶段触发条件动作初次派单新订单支付成功按评分推送给前 3 名骑手超时重派30 秒内无人确认扩大 500 米范围重新评分广播抢单重派一次仍失败全量推送先到先得异常兜底5 分钟内无人接标记为异常单通知调度员实现时注意一点回归抢单模式的订单需要确保多个骑手不会同时看到同一单还都能抢到。通用的做法是利用数据库的行锁或在 Redis 里用 SETNX 命令抢单请求到达时先尝试加锁拿到锁的骑手才进入后续的订单状态更新流程。这块内容在第 4 章并发接单部分还会展开。3. 源码拿到手先跑起来用户端 / 骑手端 / 管理后台的本地启动3.1 仓库结构长什么样一个典型全开源项目的分层方式全开源跑腿小程序系统一般不在单个仓库里放所有代码而是拆成 4 个部分用户端小程序、骑手端小程序、管理后台前端、后端服务。用户端和骑手端通常是基于 uni-app 或原生微信小程序分别开发两端复用一套接口。后端常见的选型是 Node.js MySQL 或 Java Spring Boot数据库表设计通常包含用户表、骑手表、订单表、订单状态日志表、结算表。拿到源码后第一件事不是打开微信开发者工具而是看根目录下的 README 和 init.sql 或 schema 目录。开源项目的部署文档质量参差不齐常见做法是先看有没有 docker-compose.yml有的话说明作者已经准备好了一键启动环境没有的话就只能手动初始化。典型目录结构如下delivery-system/ ├── backend/ # 后端服务NestJS / Spring Boot │ ├── src/ │ ├── sql/init.sql # 数据库初始化脚本 │ └── docker-compose.yml可选 ├── admin-web/ # 管理后台Vue3 Element Plus ├── mini-user/ # 用户端小程序uni-app 或原生 ├── mini-rider/ # 骑手端小程序 └── README.md3.2 后端启动的最小步骤数据库初始化与接口配置无论后端是哪种框架启动过程都分三步建库、导入初始化脚本、改配置。以 MySQL 为例先通过命令行创建数据库并导入脚本mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS delivery DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; mysql -uroot -p delivery backend/sql/init.sql初始化脚本会创建订单表、骑手表、用户表及基础数据。导入后检查 backend 目录下的环境配置文件通常是 .env 文件DB_HOST127.0.0.1 DB_PORT3306 DB_USERroot DB_PASSWORDyour_password DB_NAMEdelivery REDIS_URLredis://127.0.0.1:6379 # 小程序端调用后端时通过这个地址访问 BASE_URLhttp://localhost:3000/api # 微信小程序的 appId 和 appSecret用于登录换 openid WECHAT_APP_ID WECHAT_APP_SECRET注意本地开发阶段如果没有配置企业主体的微信小程序 appId建议先关闭微信登录鉴权直接走验证码登录或测试账号登录代码里一般有对应的配置开关。配置完成后安装依赖并启动cd backend npm install npm run start:dev看到类似Server is running on http://localhost:3000的日志就说明后端已经起来了。此时可以用 curl 验证接口是否正常响应curl http://localhost:3000/api/health # 期望返回 {status:ok} 类似的 JSON3.3 微信小程序端修改加载页、设置动态标题、关闭权限校验用户端和骑手端代码导入微信开发者工具时需要注意两个高频问题。第一个是编译报错提示npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。这个问题出在 Windows 的 PowerShell 执行策略与项目代码无关。解决办法是以管理员身份打开 PowerShell执行Set-ExecutionPolicy -Scope CurrentUser RemoteSigned然后重新运行 npm install。第二个问题是小程序的加载页面。很多开源项目把加载页写死在 app.json 或页面配置里修改刚进入的加载页面时需要同时改两个地方首先是 project.config.json 里的启动页面配置决定开发者工具打开时展示哪个页面其次是首页跳转逻辑通常在 app.js 的 onLaunch 或首页的 onLoad 里根据用户角色跳到用户端或骑手端。设置小程序动态标题的场景更多因为订单状态变化时希望标题栏直接反映配送进度。在用户端订单详情页拿到订单状态后用 uni.setNavigationBarTitle 或 wx.setNavigationBarTitle 设置// 订单状态映射 // 2待取件 3配送中 4已完成 const titleMap { 2: 等待骑手取件, 3: 商品配送中, 4: 订单已完成 }; uni.setNavigationBarTitle({ title: titleMap[order.status] || 订单详情 });这段代码放在订单状态推送的回调里用户停留在详情页时也能实时更新标题。实现逻辑很简单但这是提升骑手和用户两端体验成本最低的一个改法。3.4 管理后台与联调从生成二维码到触发 wx.openLocation管理后台负责骑手审核、订单监控、费率设置。本地联调时管理后台与后端跨域问题最常出现。开源项目一般通过 Nginx 代理或后端 CORS 中间件解决开发环境启动管理后台后确认浏览器开发工具里没有 CORS 报错即可。跑腿场景里有一个很容易被忽略的功能点是打开地图导航。用户端或骑手端需要展示取件地点时代码里通常使用wx.openLocation打开地图组件wx.openLocation({ latitude: 31.2304, longitude: 121.4737, name: order.pickupAddress, address: order.pickupDetail, scale: 18 });这个接口需要传入数字类型的经纬度从后端返回的字符串必须做 parseFloat 转换很多项目在这里踩坑导致点击地址后地图打不开。联调的时候先在浏览器里直接请求后端接口确认经纬度字段确实有值再去排查小程序的调用链。4. 预约取件与订单状态机把业务流写成可回溯的流水4.1 订单表与状态日志表的设计预约取件功能比即时单多一个时间维度订单表需要额外存储期望取件时间。一个精简的表结构如下CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, rider_id BIGINT DEFAULT NULL, pickup_address VARCHAR(255) NOT NULL, delivery_address VARCHAR(255) NOT NULL, pickup_lat DECIMAL(10, 6) NOT NULL, pickup_lng DECIMAL(10, 6) NOT NULL, delivery_lat DECIMAL(10, 6) NOT NULL, delivery_lng DECIMAL(10, 6) NOT NULL, expected_pickup_time DATETIME DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0, amount DECIMAL(10, 2) NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status (status), KEY idx_expected_pickup (expected_pickup_time), KEY idx_rider_id (rider_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE order_status_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, from_status TINYINT NOT NULL, to_status TINYINT NOT NULL, operator_type TINYINT NOT NULL COMMENT 1用户 2骑手 3系统 4管理员, operator_id BIGINT NOT NULL, remark VARCHAR(255) DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;orders 表里的 status 是当前状态order_status_log 记录每一次状态跳转两者缺一不可。业务上经常需要回答“这单为什么逾期了”“骑手几点确认的取件”只有状态日志能回答这类问题。index 设计上按状态查待派单列表、按期望取件时间查预约单是最高频的查询路径这两个索引必须加上。4.2 订单状态机创建 → 已支付 → 已接单 → 取件中 → 配送中 → 已完成跑腿系统订单状态机的常见状态定义状态值含义触发动作0待支付用户提交订单1已支付待派单支付回调成功2已接单骑手确认接单3取件中骑手点击已取件4配送中取件后进入配送5已完成用户确认收货或系统自动确认-1已取消用户取消或超时取消状态机的关键约束是禁止非法跳转比如已取消的订单不能变成配送中。严谨的做法是用一张状态机表维护允许的跳转关系业务代码里通过校验函数判断。大部分开源项目不会做得这么重通常只在订单服务里加一个状态更新统一入口所有状态变更都经过这个函数。const VALID_TRANSITIONS: Recordnumber, number[] { 0: [1, -1], 1: [2, -1], 2: [3, -1], 3: [4], 4: [5], 5: [] }; export function canTransition(from: number, to: number): boolean { return VALID_TRANSITIONS[from]?.includes(to) ?? false; }预约取件单在状态 1 时有个额外动作定时任务提前 30 分钟扫描当天所有预约单筛选出“临近取件时间但还没有骑手接单”的订单优先进入自动派单队列。这保证了预约单不会被漏掉。4.3 骑手端收到订单一瞬间发生了什么系统派单推送给骑手端链路是后端通过 WebSocket 向指定骑手连接推送消息。骑手端收到新订单消息后弹窗模态框展示订单摘要骑手点击“确认接单”前端发起请求// 骑手端确认接单 const res await uni.request({ url: /api/orders/accept, method: POST, data: { orderId: order.id, riderId: getApp().globalData.riderId } });后端处理逻辑包含三个步骤校验订单状态是否为待接单、校验骑手是否在可用状态、用乐观锁条件更新订单表。三者缺一不可否则会出现两个骑手同时接同一单的严重事故。4.4 并发抢单的正确姿势乐观锁 / 事务广播抢单模式下并发是最难处理的。假设两个骑手几乎同时提交接单请求后端收到两个请求都查出来订单状态是 1待接单如果直接更新就可能重复接单。解决方法是把状态校验和状态更新合并为一条带条件的 SQLUPDATE orders SET rider_id ?, status 2 WHERE id ? AND status 1;执行这条 UPDATE 后检查影响行数。affected rows 等于 1 说明抢单成功等于 0 说明订单已被别人抢走直接返回“手慢了”。这条 SQL 利用了行锁后到的请求会等先到的请求提交后再执行拿到的是最新状态所以不会出现重复更新问题。提示不要在小程序端做“本单是否可抢”的判断这个判断必须落在后端且必须在状态更新语句里完成。前端判断只是体验优化后端条件更新才是正确性保障。再用事务把订单状态更新和状态日志插入包在一起保证要么状态变更与日志同时成功要么同时回滚START TRANSACTION; UPDATE orders SET rider_id 1001, status 2 WHERE id 500 AND status 1; SELECT ROW_COUNT() INTO affected; -- affected 1 才继续否则 ROLLBACK INSERT INTO order_status_log (order_id, from_status, to_status, operator_type, operator_id) VALUES (500, 1, 2, 2, 1001); COMMIT;5. 上线后要盯的三个指标和一个抓包技巧5.1 派单成功率低于 95% 时先查什么派单成功率是系统健康度的第一指标统计口径为自动派单及广播回流后骑手最终确认接单的订单占比。如果低于 95%优先排查 maxDispatchDistance 是否设置过小用一段 SQL 看看被过滤订单的距离分布SELECT ROUND(AVG(distance), 1) AS avg_distance, MAX(distance) AS max_distance FROM orders WHERE status 1 AND created_at NOW() - INTERVAL 1 DAY;平均距离高出最大派单距离的一半时说明这个参数需要调大。另外一个常被忽略的原因是骑手端不在线骑手 App 退到后台超过一定时间后微信小程序的 WebSocket 会被系统挂起导致推送到达但用户没有感知。骑手端需要在前台运行时接收消息并把离线状态上报到后端。5.2 骑手 App 电量与定位上报频率的取舍骑手端持续上报位置可以提升派单精度但对手机电量消耗明显。实际运营中通常采用混合策略骑手活跃状态每 10 秒上报一次定位空闲状态拉长到 30 秒。后端可以用 Redis 缓存骑手最近位置和最后活跃时间派单评分时优先读缓存骑手上报时再回源更新数据库。位置上报间隔与派单成功率的对应关系可以参考的经验值10 秒间隔下派单成功率约 98%30 秒间隔下降到 92% 左右。校园跑腿场景建议保持 10 秒间隔因为骑手活动范围小、移动速度快位置过期会让距离计算严重失真。5.3 用一套可复现的命令验证全流程每次发布前在测试环境跑一遍以下命令可以快速确认核心链路没有被改坏。前提是测试环境有一个测试骑手账号和一个测试用户账号# 1. 创建订单 curl -X POST http://localhost:3000/api/orders \ -H Content-Type: application/json \ -d { userId: 1, pickupAddress: 东门取件点, deliveryAddress: 3号楼, pickupLat: 31.2304, pickupLng: 121.4737, deliveryLat: 31.2310, deliveryLng: 121.4740, expectedPickupTime: 2025-01-15 12:30:00 } # 2. 模拟支付回调测试环境可跳过微信支付 curl -X POST http://localhost:3000/api/mock/pay \ -H Content-Type: application/json \ -d {orderNo: 返回的order_no} # 3. 查询订单当前状态 curl http://localhost:3000/api/orders/500 # 4. 模拟骑手接单与取件 curl -X POST http://localhost:3000/api/orders/500/accept \ -H Content-Type: application/json \ -d {riderId: 1001}验证的标准很简单步骤 2 完成后步骤 3 返回 status1步骤 4 执行成功后再查返回 status2 且 rider_id 被正确写入。整个过程如果超过 30 秒说明接口链路里有明显耗时点应该先压测再放量。5.4 抓包调试微信小程序的一个实际技巧小程序端联调时如果接口请求成功但页面数据异常建议抓包对比实际返回的 JSON 结构与前端预期结构。以微信开发者工具为例在“详情 - 本地设置”里勾选“不校验合法域名”然后使用本地抓包工具监听 HTTP 请求。注意抓包时的 HTTPS 证书校验问题小程序端请求的域名如果是测试环境的 IP 地址通常在工具里关闭校验即可看到完整请求和响应报文。查看具体请求时重点看返回数据里的字段命名风格。后端常见的字段是驼峰式pickupAddress而部分模板代码里把字段名映射成了下划线风格pickup_address这种不一致会直接导致页面渲染空白。错误信息出现在 Network 面板的 Response 里直接对照模板数据结构改动映射即可解决。先把返回值的 JSON 复制出来格式化再和页面 data 里绑定的字段逐一比对比猜代码快得多。本文还有配套的精品资源点击获取