
简介这是一套面向同城跑腿创业团队与外包开发者的完整技术方案基于Fastadmin、ThinkPHP与Uniapp构建覆盖用户端、骑手端和运营后台三端支持帮取、帮送两种业务模式可私有化部署且源码无加密。包内共2000个文件以1079个js、265个html、235个vue、213个json为主另有142个md说明文档、58个css样式、2个sql建表脚本及少量xml、sh等配置压缩包约43.97MB前后端与小程序代码结构完整。功能层面涵盖按距离重量计价、临时加价、预约取件、跑腿小费、物品保价、地图选点导航以及一键抢单、主动接单、自由开工、系统派单与智能派单等接单派单机制并区分兼职与全职骑手佣金结算。目前已有105人学习下载适合需要快速搭建跑腿平台、研究派单算法与计价规则的中高级开发者参考复用。1. 同城跑腿系统选型为什么 Fastadmin ThinkPHP Uniapp 是中小团队最稳的起手式去年帮一个三线城市的本地生活团队做技术复盘他们花了四个月用某开源商城改跑腿最后卡在骑手端定位漂移和订单状态机混乱上上线延期两个月。问题不在业务复杂度而在选型商城系统的订单模型和跑腿的「取送双地址 骑手实时轨迹 帮取帮送两种模式」根本对不上。后来换成 Fastadmin ThinkPHP 做运营后台和 APIUniapp 做用户端和骑手端两周跑通核心链路。这套组合的价值在于Fastadmin 自带 CRUD 生成和权限管理ThinkPHP 的 ORM 和中间件生态成熟Uniapp 一套代码覆盖微信小程序、H5 和 App对预算有限、人手不足的团队来说试错成本最低。这篇文章面向的是准备自建同城跑腿系统的开发者或技术负责人从环境搭建、数据建模、双端对接到骑手调度和避坑按可复现的路径讲清楚。如果你正在评估「帮取帮送」这类业务能不能用这套技术栈落地下面的内容可以直接抄作业。2. 环境搭建与 Fastadmin 后台初始化从零到能登录的完整命令2.1 ThinkPHP 运行环境的最低要求与版本选择Fastadmin 基于 ThinkPHP 5.1 或 6.x 分支当前主流稳定版本是 ThinkPHP 6.0 Fastadmin 1.3.x。PHP 版本建议 7.4 或 8.0MySQL 5.7Nginx 1.18。不要用 PHP 8.1 以上Fastadmin 部分依赖包在 8.1 会有兼容性告警。Composer 必须装因为 Fastadmin 的插件机制和第三方 SDK 都走 Composer 管理。我一般会先确认服务器时区和 MySQL 的sql_mode跑腿系统对时间敏感ONLY_FULL_GROUP_BY不关掉会在订单统计查询里翻车。命令如下# 查看 PHP 版本和扩展 php -v php -m | grep -E pdo_mysql|curl|gd|redis # 关闭 MySQL 严格模式在 my.cnf 的 [mysqld] 下添加 sql_modeNO_ENGINE_SUBSTITUTION # 设置时区 timedatectl set-timezone Asia/Shanghai逻辑说明pdo_mysql是 ThinkPHP 连接数据库的基础curl用于调用地图 API 和支付回调gd用于生成海报和骑手接单凭证redis用于订单队列和骑手位置缓存。参数上sql_mode只保留NO_ENGINE_SUBSTITUTION是为了避免GROUP BY查询报错跑腿后台的「区域订单统计」和「骑手绩效」都会用到聚合查询。2.2 Fastadmin 下载与安装三条命令跑通后台Fastadmin 官方推荐用 Composer 创建项目但国内网络直接拉取可能超时我一般用 Gitee 镜像或完整包。以下命令假设你已经在项目根目录# 方式一Composer 创建网络好时用 composer create-project karsonzhang/fastadmin:1.3.4 fastadmin-run # 方式二完整包解压后进入目录 cd fastadmin-run cp .env.sample .env # 修改 .env 数据库配置 # [database] # type mysql # hostname 127.0.0.1 # database paotui # username root # password your_password # hostport 3306 # prefix fa_ # 导入初始 SQL mysql -uroot -p paotui fastadmin.sql # 启动内置服务器测试 php think run --host 0.0.0.0 --port 8000逻辑说明.env文件是 ThinkPHP 6 的环境配置入口Fastadmin 的数据库前缀默认fa_跑腿业务表建议统一用pt_前缀区分。php think run是 ThinkPHP 自带的开发服务器生产环境必须换成 Nginx PHP-FPM。参数上hostport默认 3306如果 MySQL 改了端口要同步。安装完成后访问http://你的IP:8000/index.php/admin默认账号admin密码在安装时设置。2.3 跑腿业务的数据表设计订单表、骑手表、地址表Fastadmin 的 CRUD 生成器可以快速建表但跑腿的核心表结构必须手动设计。以下是最小可用的三张表-- 订单表支持帮取和帮送两种模式 CREATE TABLE pt_order ( id int(11) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, user_id int(11) NOT NULL COMMENT 下单用户ID, rider_id int(11) DEFAULT 0 COMMENT 骑手ID0为未接单, type tinyint(1) NOT NULL DEFAULT 1 COMMENT 1帮取 2帮送, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0待接单 1已接单 2取件中 3配送中 4已完成 5已取消, from_address varchar(255) NOT NULL COMMENT 取件地址, from_lat decimal(10,7) DEFAULT 0 COMMENT 取件纬度, from_lng decimal(10,7) DEFAULT 0 COMMENT 取件经度, to_address varchar(255) NOT NULL COMMENT 送达地址, to_lat decimal(10,7) DEFAULT 0 COMMENT 送达纬度, to_lng decimal(10,7) DEFAULT 0 COMMENT 送达经度, goods_info varchar(500) DEFAULT COMMENT 物品信息, fee decimal(10,2) DEFAULT 0 COMMENT 配送费, remark varchar(255) DEFAULT COMMENT 备注, create_time int(11) DEFAULT 0, accept_time int(11) DEFAULT 0 COMMENT 接单时间, finish_time int(11) DEFAULT 0 COMMENT 完成时间, PRIMARY KEY (id), KEY idx_rider_status (rider_id,status), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT跑腿订单表; -- 骑手表 CREATE TABLE pt_rider ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL COMMENT 关联用户ID, real_name varchar(50) DEFAULT COMMENT 真实姓名, phone varchar(20) DEFAULT COMMENT 手机号, id_card varchar(20) DEFAULT COMMENT 身份证号, status tinyint(1) DEFAULT 0 COMMENT 0待审核 1正常 2禁用, online tinyint(1) DEFAULT 0 COMMENT 0离线 1在线, lat decimal(10,7) DEFAULT 0 COMMENT 当前纬度, lng decimal(10,7) DEFAULT 0 COMMENT 当前经度, update_time int(11) DEFAULT 0, PRIMARY KEY (id), KEY idx_online (online,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT骑手表;逻辑说明type字段区分帮取和帮送帮取是「取件地址 → 用户指定送达地址」帮送是「用户地址 → 指定送达地址」两者在订单状态流转上一致但计价规则不同。status的状态机是跑腿系统的核心0 到 4 的顺序不能乱骑手端和用户端都依赖这个字段做 UI 切换。索引idx_rider_status用于骑手端「我的订单」查询idx_online用于后台筛选在线骑手。参数上经纬度用decimal(10,7)保证精度到厘米级fee用decimal(10,2)避免浮点误差。3. Uniapp 双端开发用户端下单与骑手端接单的联调路径3.1 Uniapp 项目初始化与 manifest 配置要点用户端和骑手端建议放在同一个 Uniapp 项目里通过角色路由区分减少维护成本。用 HBuilderX 创建「默认模板」项目然后修改manifest.json。关键配置项{ name: 优创同城跑腿, appid: __UNI__XXXXXXX, description: 同城跑腿系统, versionName: 1.0.0, versionCode: 100, transformPx: false, app-plus: { usingComponents: true, nvueStyleCompiler: uni-app, compilerVersion: 3, splashscreen: { alwaysShowBeforeRender: true, waiting: true, autoclose: true, delay: 0 }, modules: { Geolocation: {}, Maps: {}, Payment: {} }, distribute: { android: { permissions: [ uses-permission android:name\android.permission.ACCESS_FINE_LOCATION\/, uses-permission android:name\android.permission.ACCESS_COARSE_LOCATION\/, uses-permission android:name\android.permission.CAMERA\/ ] }, ios: { privacyDescription: { NSLocationWhenInUseUsageDescription: 需要获取您的位置用于计算配送距离, NSCameraUsageDescription: 需要拍照上传物品信息 } } } }, mp-weixin: { appid: wxXXXXXXXX, setting: { urlCheck: false, es6: true, postcss: true, minified: true }, permission: { scope.userLocation: { desc: 你的位置信息将用于跑腿下单 } }, requiredPrivateInfos: [getLocation, chooseLocation] } }逻辑说明transformPx设为 false 是为了用 Uniapp 的 rpx 单位跑腿界面在手机端需要自适应。modules里必须声明 Geolocation 和 Maps否则打包 App 后定位 API 返回失败。微信小程序的requiredPrivateInfos是 2022 年后新增的隐私协议要求不配置getLocation会直接报错。参数上versionCode每次发版必须递增Android 应用市场审核会检查。3.2 用户端下单页地址选择与费用预估的实现用户端核心是下单页需要调用地图选点、计算距离、预估费用。以下是一个可复用的下单逻辑片段// pages/order/create.vue export default { data() { return { fromAddress: , toAddress: , fromLat: 0, fromLng: 0, toLat: 0, toLng: 0, distance: 0, fee: 0, orderType: 1 // 1帮取 2帮送 }; }, methods: { // 选择地址 async chooseAddress(type) { const res await uni.chooseLocation({ latitude: this.fromLat || 39.908, longitude: this.fromLng || 116.397 }); if (type from) { this.fromAddress res.address; this.fromLat res.latitude; this.fromLng res.longitude; } else { this.toAddress res.address; this.toLat res.latitude; this.toLng res.longitude; } this.calcFee(); }, // 计算距离和费用 calcFee() { if (!this.fromLat || !this.toLat) return; const distance this.getDistance( this.fromLat, this.fromLng, this.toLat, this.toLng ); this.distance distance; // 起步价5元含3公里超出每公里2元 const basePrice 5; const baseDistance 3; const extraPrice 2; if (distance baseDistance) { this.fee basePrice; } else { this.fee basePrice (distance - baseDistance) * extraPrice; } // 帮取模式加收2元 if (this.orderType 1) { this.fee 2; } }, // 球面距离计算 getDistance(lat1, lng1, lat2, lng2) { const rad Math.PI / 180; const a rad * lat1; const b rad * lat2; const theta lng1 - lng2; const c rad * theta; let d Math.sin(a) * Math.sin(b) Math.cos(a) * Math.cos(b) * Math.cos(c); d Math.acos(Math.min(d, 1)); return d * 6371; // 地球半径6371公里 } } };逻辑说明uni.chooseLocation是 Uniapp 封装的原生地图选点微信小程序和 App 都支持H5 端需要配置地图 key。getDistance用球面余弦定理计算直线距离实际配送距离应该用地图 API 的骑行路径规划但直线距离作为预估足够。参数上起步价和超出单价应该从后台配置接口读取不要硬编码方便运营调价。帮取模式加收 2 元是业务规则可以在后台做成可配置项。3.3 骑手端接单与状态流转轮询还是 WebSocket骑手端需要实时接收新订单常见做法有两种轮询和 WebSocket。轮询实现简单但延迟高、耗电WebSocket 实时性好但需要服务端维护连接。我一般推荐混合方案骑手在线时用 WebSocket 推送新订单离线时用轮询兜底。// 骑手端 WebSocket 连接 let socketTask null; function connectSocket(riderId) { socketTask uni.connectSocket({ url: wss://your-domain.com/wss?rider_id${riderId}, success: () { console.log(WebSocket 连接成功); } }); socketTask.onMessage((res) { const data JSON.parse(res.data); if (data.type new_order) { // 播放提示音并弹窗 uni.showModal({ title: 新订单, content: 取件${data.order.from_address}\n送达${data.order.to_address}, success: (modalRes) { if (modalRes.confirm) { acceptOrder(data.order.id); } } }); } }); socketTask.onClose(() { console.log(WebSocket 断开3秒后重连); setTimeout(() connectSocket(riderId), 3000); }); } // 接单 async function acceptOrder(orderId) { const res await uni.request({ url: https://your-domain.com/api/rider/accept, method: POST, data: { order_id: orderId }, header: { token: uni.getStorageSync(token) } }); if (res.data.code 1) { uni.showToast({ title: 接单成功 }); uni.navigateTo({ url: /pages/rider/order-detail?id orderId }); } else { uni.showToast({ title: res.data.msg, icon: none }); } }逻辑说明uni.connectSocket在 App 和微信小程序都可用H5 端需要服务端支持 WSS。onClose里做重连是必须的移动网络切换时 WebSocket 会断开。参数上rider_id用于服务端识别骑手身份接单接口的token从本地存储读取服务端校验骑手状态和订单状态。注意接单接口必须做并发控制两个骑手同时点接单只能有一个成功用 MySQL 的UPDATE ... WHERE status0影响行数判断。4. 运营后台与调度逻辑Fastadmin 里的订单管理和骑手审核4.1 用 Fastadmin CRUD 生成订单管理页面Fastadmin 的 CRUD 生成器可以一键生成订单的增删改查但跑腿订单不需要「新增」和「删除」只需要「列表」和「详情」。在后台命令行执行# 生成订单管理 CRUD php think crud -t pt_order -c order/Order -m OrderModel # 生成骑手管理 CRUD php think crud -t pt_rider -c rider/Rider -m RiderModel # 生成菜单 php think menu -c order/Order php think menu -c rider/Rider逻辑说明-t指定表名-c指定控制器路径-m指定模型。生成后需要手动修改控制器去掉add和del方法因为订单不允许后台手动新增和删除。参数上Fastadmin 的权限节点会自动注册需要在「权限管理」里给运营角色分配「订单列表」和「骑手审核」权限。4.2 骑手审核与在线状态管理骑手注册后需要后台审核审核通过才能接单。Fastadmin 的列表页自带「审核」按钮但需要自定义操作。在application/admin/controller/rider/Rider.php里添加// 审核通过 public function pass($ids null) { $row $this-model-get($ids); if (!$row) { $this-error(骑手不存在); } $row-status 1; $row-save(); $this-success(审核通过); } // 禁用骑手 public function forbid($ids null) { $row $this-model-get($ids); if (!$row) { $this-error(骑手不存在); } $row-status 2; $row-online 0; $row-save(); $this-success(已禁用); }逻辑说明$this-model-get($ids)获取骑手记录status字段控制审核状态。禁用时同时把online设为 0防止骑手继续接单。参数上$ids是 Fastadmin 表格传过来的主键支持批量操作时用逗号分隔需要explode处理。4.3 订单调度手动派单与自动派单的取舍小团队初期建议用「手动派单 骑手抢单」混合模式。后台可以手动指派订单给指定骑手骑手端也可以主动抢单。自动派单算法复杂需要综合考虑骑手距离、当前负载、历史评分初期没必要上。// 后台手动派单 public function assign($orderId, $riderId) { $order OrderModel::get($orderId); if ($order-status ! 0) { $this-error(订单已被接单); } $rider RiderModel::get($riderId); if ($rider-status ! 1 || $rider-online ! 1) { $this-error(骑手不在线或未审核); } $order-rider_id $riderId; $order-status 1; $order-accept_time time(); $order-save(); // 推送通知给骑手 $this-pushToRider($riderId, $order); $this-success(派单成功); }逻辑说明派单前必须检查订单状态和骑手状态避免重复派单。pushToRider是自定义方法通过 WebSocket 或极光推送通知骑手。参数上accept_time记录接单时间用于计算骑手响应时长。5. 避坑与排查跑腿系统上线前必须处理的五个问题5.1 定位漂移导致取送地址偏差超过 500 米现象骑手端显示的距离和用户端不一致有时差出 1 公里。原因Uniapp 的uni.getLocation默认返回 GCJ02 坐标系但后台存储和地图 API 可能用 WGS84坐标系不统一。解决统一用 GCJ02在manifest.json里配置coordType: gcj02后台存储时不做转换地图 API 调用时也传 GCJ02。5.2 订单状态机混乱骑手点「已送达」但用户没收到现象骑手端可以跳过「取件中」直接点「已送达」。原因前端没有做状态校验后端接口也没有校验前置状态。解决后端在updateStatus接口里加状态流转判断只允许0→1→2→3→4的顺序非法流转直接返回错误。前端根据当前状态只显示下一个合法操作按钮。5.3 微信小程序审核被拒类目和隐私协议不符现象提交微信审核时提示「服务类目与功能不符」。原因跑腿属于「生活服务 跑腿代购」但很多开发者选了「工具 效率」。解决在微信公众平台把类目改成「生活服务 跑腿代购」并在manifest.json的mp-weixin里配置requiredPrivateInfos包括getLocation、chooseLocation、chooseAddress。5.4 骑手端 App 在后台被杀后收不到新订单现象骑手锁屏或切换应用后WebSocket 断开新订单推送丢失。原因Android 和 iOS 对后台进程限制严格。解决接入厂商推送通道极光推送、个推WebSocket 只在前台用后台用推送通知。Uniapp 端在manifest.json里配置推送模块服务端调用推送 API 发送通知。5.5 订单金额计算出现 0.01 元误差现象用户端显示 12.30 元后台统计 12.29 元。原因JavaScript 浮点数计算精度问题0.1 0.2 ! 0.3。解决所有金额计算用整数分前端显示时除以 100。后端 PHP 用bcmath扩展做精确计算数据库存decimal(10,2)。6. 从能跑到好用跑腿系统的压测指标与骑手调度优化技巧系统上线只是开始真正决定留存的是高峰期能不能扛住。我一般会在上线前做一轮压测重点看三个指标订单创建接口的 P99 延迟、WebSocket 并发连接数、MySQL 订单表的写入 TPS。用ab或wrk对/api/order/create压测目标是在 500 并发下 P99 低于 200ms。WebSocket 用websocket-bench模拟 1000 个骑手同时在线观察服务端内存和连接稳定性。骑手调度优化上初期不要追求「最优派单」先做到「不超时」。我的经验是订单创建后 30 秒内没有骑手接单后台自动把订单推送给距离取件地址 3 公里内、当前负载小于 3 单的骑手。这个逻辑用 Redis 的GEO命令实现# 骑手位置写入 Redis GEO GEOADD rider_online 116.397 39.908 rider_1001 # 查询取件地址 3 公里内的骑手 GEORADIUS rider_online 116.400 39.910 3 km WITHDIST ASC逻辑说明GEOADD在骑手每次上报位置时更新GEORADIUS按距离排序返回附近骑手。参数上WITHDIST返回距离ASC按由近到远排序。拿到骑手列表后再过滤掉负载已满的逐个推送。这个方案比全表扫描 MySQL 快一个数量级。另一个技巧是订单状态变更时用 Redis 队列异步写 MySQL避免高峰期直接写库导致锁等待。用 ThinkPHP 的think-queue扩展把订单创建、状态变更、骑手位置更新都丢进队列消费者进程慢慢写。这样接口响应时间能压到 50ms 以内。最后说一个血泪教训跑腿系统的「帮取」和「帮送」在计价上一定要分开配置我见过一个团队把两者混在一起算结果帮取订单多收了用户 3 块钱客诉率直接翻倍。后台的计价规则表要支持按类型、按区域、按时间段配置别偷懒写死在代码里。希望帮到你。本文还有配套的精品资源点击获取