1. 项目概述1.1 这是什么项目校园跑腿这个需求几乎每个大学城都存在。代拿快递、带饭、代买奶茶、打印资料、临时取货这些都是高频刚需的小事但就是这些小事占据了大学生的碎片时间。我在做这个校园生活服务系统时核心思路很简单做一个连接“下单学生”和“跑腿骑手”的双端平台学生通过微信小程序下单骑手通过安卓APP接单后端统一用PHP框架来支撑业务逻辑。整个系统设计的角色有三个普通学生下单方、骑手接单配送方、管理员平台运营方。学生端最重要的需求是操作快、支付方便所以选微信小程序用完即走不用下载安装骑手端需要常驻后台接收订单、处理配送状态所以做成安卓APP更合适后端管理后台用来处理订单纠纷、用户审核、收益结算不管是Thinkphp还是Laravel都能扛住这个业务量。标题里把Thinkphp和Laravel放在一起说明这个项目在技术选型上有一个权衡过程。我也确实把两个框架都实际用了一遍各有优劣后面细说。这里先给结论如果你没有历史包袱建议直接用Laravel如果你要接学校现有系统或者团队里有人更熟Thinkphp那用Thinkphp也完全能落地。1.2 适合谁看这个项目适合几类人一是正在做毕业设计或课程设计的学生这个题目属于“传统业务移动端管理端”的综合型项目能覆盖后端开发、小程序开发、安卓开发、数据库设计、部署运维一整条链路用来做毕设的完整度很高二是在校创业团队想快速搭一个跑腿平台MVP最小可行性产品验证需求三是刚入行想系统接触“小程序APP后台API”三层架构的开发者。如果你完全没碰过PHP建议先补一下基础语法和MVC概念再来看后面的内容如果你有基础可以照着这个方案直接开干。我写这篇博文会尽量把“为什么这么做”讲透而不是只扔一段能跑但说不清楚的代码。2. 整体方案设计与技术选型2.1 核心功能模块拆解一个跑腿系统表面上看就是下单、接单、配送三个动作但落地到代码里需要细分成下面这些模块用户模块微信授权登录、手机号绑定、学生认证可选、骑手入驻审核、账号余额与钱包订单模块发布订单、订单分类取快递、带饭、代买、其他、期望送达时间、配送地址、跑腿费设置、订单状态流转支付模块微信支付下单、支付回调、退款超时取消、用户取消、余额支付接单模块骑手抢单、平台派单可选、订单抢单锁、超时释放配送模块取件码上传、送达确认、位置轨迹可选、异常上报结算模块骑手收益统计、提现申请、平台抽成管理后台用户管理、订单列表与详情、审核骑手、投诉处理、数据报表我在设计数据表的时候核心表有用户表带角色区分、订单表、订单状态流转日志表、骑手认证表、提现记录表、系统配置表。订单表要特别注意字段设计尤其是地址信息的冗余。不要傻乎乎只存一个经纬度或者一个地址ID要把起点地址、终点地址、收货人、联系方式、备注一次性整合进订单表否则查列表、做统计的时候会JOIN到怀疑人生。2.2 Thinkphp和Laravel怎么选这个问题我相信很多人纠结过。在校园跑腿这个业务量级下两个框架都能轻松承载真正的区别在于开发体验、生态和团队熟练度。Laravel的优势是设计现代化Eloquent ORM写起来很舒服队列、事件、任务调度这些功能开箱即用而且官方文档极其详尽。对于跑腿系统来说订单超时处理、定时结算报表这类功能Laravel的Task Scheduling配合队列实现起来非常顺手。劣势是对服务器要求略高PHP版本要求8.1以上虚拟主机跑起来有些吃力而且中文资料虽然多但版本迭代快网上很多老教程都是Laravel 5.x时代的照着写会踩坑。Thinkphp的优势是上手门槛低中文文档完善国内教程和问题解决方案特别多部署也灵活5.x版本在PHP 5.6都能跑。对很多学校里的项目来说Thinkphp的老底子教程非常多遇到问题很容易搜到答案。劣势是框架本身的设计理念相对古老有些地方写起来略“糙”但业务逻辑不复杂的项目完全够用。我实际做这个项目时选择了Laravel作为主后端。原因是我需要队列来处理订单超时和状态消息推送Laravel在这一块几乎是零成本而你用Thinkphp的话消息推送和异步任务需要自己实现或者引入额外的类库多少有点折腾。如果你做毕设并且时间充裕建议用Laravel因为答辩的时候你讲“为什么用Laravel”比讲Thinkphp更容易展示你对现代开发实践的了解。2.3 整体架构流程整个系统的数据流向是这样的用户在小程序端下单 → 小程序调用后端下单API → 后端创建订单并调用微信支付接口 → 支付成功后返回小程序进入订单详情页 → 后端通过WebSocket或者小程序订阅消息通知骑手端有新订单 → 骑手在安卓APP上点击抢单 → 后端锁定订单分配骑手 → 骑手取货送货 → 用户确认收货 → 骑手获得收益。这里面有一个技术点需要提前想好小程序和安卓APP之间的实时通知。用轮询的话实现简单但实时性差用WebSocket需要维护长连接成本高用小程序订阅消息只能被动推送不能双向通信。我最终采用的方式是“骑手端用WebSocket长连接小程序端用订阅消息轮询兜底”。因为骑手端是安卓原生APP常驻后台维持一个WebSocket长连接很自然效率也高而小程序端本身不适合长期后台运行用户也没有实时刷新订单状态的需求等骑手接单后通过订阅消息和订单详情刷新就足够了。3. 数据库设计与后端实现3.1 数据表设计要点跑腿系统的核心表结构我建议这样设计省略了部分无关字段用户表id、openid小程序唯一标识、phone、nickname、avatar、rolestudent/rider/admin、balance、status、created_at订单表id、order_sn业务订单号、user_id下单人、rider_id接单人、type1取件/2带饭/3代买/4其他、pickup_address、pickup_contact、pickup_phone、delivery_address、delivery_contact、delivery_phone、remark、goods_fee商品费用、delivery_fee配送费、total_fee、pay_status、status0待支付/1待接单/2已接单/3配送中/4已完成/5已取消、cancel_reason、created_at、paid_at、accepted_at、finished_at骑手认证表id、user_id、student_id、real_name、id_card_front、id_card_back、status、review_remark、reviewed_at提现记录表id、rider_id、amount、status、apply_time、audit_time订单状态日志表所有状态变化都记录在这里方便对账和纠纷处理表设计有几个注意点order_sn不能直接用自增ID要生成一个带日期格式的唯一业务号比如 202506031230001234因为前端展示、微信支付回调、客服查询都要用这个号而且不能被简单猜到。金额字段用decimal(10,2)而不是floatPHP浮点数计算金额迟早出问题这个坑我踩过。所有时间字段用datetime或timestamp统一存储为UTC前端展示时再转换避免时区混乱。3.2 登录态与Session处理实战这大概是整个项目里最容易踩坑的部分。小程序端登录流程是标准的三步小程序端调用wx.login()获取临时code将code发送到后端后端调用微信接口换取openid和session_key后端生成自己的登录令牌token存redis或数据库返回给小程序小程序后续请求都带token如果你用Laravel很多人习惯直接用自带的session认证但小程序端不是浏览器每次请求并不会自动携带Cookie所以必须用token机制。我的做法是配置一个自研的auth中间件解析Authorization请求头里的token查库拿到用户信息注入到请求上下文。Thinkphp的话可以自己写一个token验证中间件或者用现成的JWT类库。不管哪个框架核心逻辑都是一样的用token替代session把用户状态“无状态化”。这里放一个关键的Laravel中间件示例// app/Http/Middleware/CheckToken.php public function handle($request, Closure $next) { $token $request-header(Authorization); $token str_replace(Bearer , , $token); $userId Redis::get(user_token: . $token); if (!$userId) { return response()-json([code 401, msg 登录已过期], 401); } $user User::find($userId); if (!$user) { return response()-json([code 401, msg 用户不存在], 401); } $request-attributes-set(current_user, $user); return $next($request); }需要注意Redis键的过期时间我的策略是30天小程序端只要保持活跃就不需要频繁重新登录。还要注意token是随机字符串带有一定复杂度不要直接用openid当token不安全且容易被人伪造。3.3 订单创建与微信支付接入订单创建的接口是整个后端最核心的接口它要完成的工作包括参数校验地址非空、金额合法、生成订单号、锁定库存如果涉及代买商品、生成支付参数、返回小程序端让用户发起支付。微信支付的流程和小程序登录类似后端调用微信支付统一下单API传入openid、订单号、金额微信返回prepay_id后端再用prepay_id生成签名参数返回给小程序小程序端调用wx.requestPayment()发起支付用户输密码确认微信服务器异步回调后端支付结果接口第6步的回调接口要特别注意安全性。必须验证签名、回调的金额要和订单表里的应付金额一致、订单状态要判断防止重复回调。我当时吃过一个亏回调里只校验了签名没校验金额虽然测试环境没事但真实场景下如果有人在支付中途修改了订单金额参数回调的金额可能和订单不一致这时候如果还贸然改订单状态就会造成资损。下面是我当时写的支付回调处理逻辑简单整理一下核心流程// 伪代码 public function wxNotify(Request $request) { $data $request-getContent(); $result WechatPay::verifyNotify($data); // 验签解密 if ($result[return_code] SUCCESS $result[result_code] SUCCESS) { $order Order::where(order_sn, $result[out_trade_no])-first(); if (!$order) { return fail; } // 校验金额防止篡改 if (bccomp($order-total_fee, $result[total_fee], 2) ! 0) { Log::error(支付回调金额不一致, $result); return fail; } // 校验订单状态防止重复处理 if ($order-pay_status 1) { return success; // 幂等返回 } DB::transaction(function () use ($order) { $order-pay_status 1; $order-status 1; // 待接单 $order-paid_at now(); $order-save(); // 推送新订单通知给骑手端 }); return success; } }中间的幂等判断极其重要。微信支付回调在网络异常时会自动重试如果没有幂等判断同一个订单可能被处理两三次导致状态错乱和重复推送。4. 小程序端开发实战4.1 小程序整体架构和页面规划小程序端我采用了比较常规的tabBar结构底部四个入口首页下单、订单列表、消息通知、个人中心。首页是这个产品最关键的一页需要让用户在两秒内完成“选类型-填地址-发单”的操作。我的布局是顶部一个订单类型选择器横向滚动的胶囊按钮中间是地址填写的入口卡片下方是历史常用的跑腿地址存在本地缓存再往下是正在热门的骑手推荐展示最近在线骑手。首页不要堆一大堆跳转入口用户来你这里是为了快速下单不是逛商城。订单类型可以做成一个json配置由后端接口动态返回这样以后加“代排队”这种新类型不用发版小程序[ { type: 1, name: 取快递, icon: express, placeholder: 请输入快递地址和取件码 }, { type: 2, name: 带饭, icon: food, placeholder: 请输入餐厅和打包要求 }, { type: 3, name: 代买, icon: shop, placeholder: 请输入商店和购买清单 }, { type: 4, name: 其他, icon: more, placeholder: 请描述需求 } ]4.2 小程序调后端API的封装小程序的网络请求不能直接用fetch要用wx.request。而且要注意调用前要先通过uni.request或者wx.request的header里带上token。我习惯封装一个request.js工具类统一处理baseURL、token注入、错误码拦截、登录过期自动跳转。一个很实用的封装示例// utils/request.js const BASE_URL https://api.example.com/api; function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method: method, data: data, header: { Content-Type: application/json, Authorization: Bearer wx.getStorageSync(token) }, success(res) { if (res.statusCode 200) { if (res.data.code 0) { resolve(res.data.data); } else if (res.data.code 401) { // token失效 wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); reject(res.data); } else { wx.showToast({ title: res.data.msg, icon: none }); reject(res.data); } } else { reject(res); } }, fail(err) { reject(err); } }); }); }用Promise封装的好处是页面里可以用async/await写业务代码时不用层层嵌套回调。4.3 地址选择器和地图选点跑腿订单的核心信息是“取送地址”所以地址选择环节要做得足够顺手。我用的是微信小程序的wx.chooseLocation接口可以直接唤起原生的地图选点不需要自己接第三方地图SDK对开发者来说省了大量工作。需要注意wx.chooseLocation的使用前置条件需要在app.json里声明 permission 权限permission: { scope.userLocation: { desc: 你的位置信息将用于选择取送地址 } }用户需要先授权地理位置权限否则调用chooseLocation会直接失败返回的数据包含 name地址名称、address详细地址、latitude、longitude我在选完地址后会把经纬度一起提交给后端后面计算骑手到取货点的距离、预估配送时长都可以用这些坐标。4.4 小程序消息订阅与状态刷新用户下单后很想知道自己的单子有没有被人接。这里有两个方案轮询和订阅消息。轮询简单就是每隔几秒调用订单详情接口缺点是费流量耗电大量请求也增加服务器压力。订阅消息是微信官方的推送能力用户主动订阅后后端可以调用微信API向用户发送一条服务通知告诉他“骑手已接单正在前往取货点”。考虑到用户体验和服务器压力我采用“关键节点订阅消息推送订单详情页下拉刷新”的方式。在下单成功页诱导用户点击“订阅消息通知”如果用户允许那么在骑手接单、订单完成两个节点就推送给用户如果用户拒绝那用户只能手动进订单列表刷新。实际的订阅率大概在60%左右已经能覆盖大部分用户。5. 安卓端骑手APP开发实录5.1 原生还是uniapp骑手端APP有两个选择用Android原生Java/Kotlin开发或者用uniapp/Futter等跨平台方案。我的建议是如果骑手端只需要安卓直接原生Kotlin开发最稳。原因骑手端对后台运行、实时推送、定位服务的依赖非常重原生能拿到系统最深层的支持跨平台框架在这些方面虽然现在做得也不错但总会有一些小问题一旦出问题排查成本很高。但如果你的骑手端还要兼容iOS或者开发周期太紧那uniapp是一个折中方案。我们团队一开始用uniapp写了一个版本后来在OTA更新、消息推送、后台服务这三个方面总感觉不如原生顺手最终骑手端还是切回了Kotlin原生。这里面的代价就是开发周期拉长了两周如果你们项目时间很紧建议一开始就选定技术路线别来回改。5.2 抢单功能的并发处理跑腿系统最刺激的一个场景就是骑手抢单。多个骑手在同一个新订单推送过来的瞬间点击“抢单”后端必须保证“一个订单只能被一个骑手抢到”。这个功能最常见的错误写法是请求进来先查订单状态如果状态是“待接单”就改成“已接单”完成。这个写法在并发正常情况下没问题但在多个骑手同时点击时会出现“超卖”两个请求都读到状态是“待接单”都去更新后一个覆盖前一个最后两个骑手都觉得是自己抢到了。解决办法之一是使用数据库的乐观锁更新时带上条件 status 1待接单如果影响行数为0说明已经被别人抢走了。$updated Order::where(id, $orderId) -where(status, 1) -update([ status 2, rider_id $riderId, accepted_at now() ]); if ($updated 0) { return response()-json([code 1, msg 手慢了订单已被抢走]); }这种方式比“先查后改”安全得多因为数据库的UPDATE操作本质上是行锁同一时刻只有一个请求能真正把状态从1改成2其余请求受影响行数都是0。另外还可以配合Redis的分布式锁$lockKey order_lock_ . $orderId; $locked Redis::set($lockKey, $riderId, EX, 5, NX); if (!$locked) { return response()-json([code 1, msg 手慢了订单已被抢走]); }Redis锁作为前置拦截数据库乐观锁作为最终兜底两套一起上基本可以应对绝大多数抢单场景。5.3 实时推送与后台运行骑手端接到新订单的实时性非常重要没人希望骑手隔五分钟才看到新订单用户早就等急了。我用的方案是Netty或Workerman搭建的WebSocket服务和主后端PHP服务分开部署。但国内安卓ROM对后台运行的限制极其严格。App退到后台后WebSocket连接会被系统杀掉这也是安卓开发最让人头疼的问题之一。解决办法没有银弹只能多管齐下使用前台服务startForegroundService常驻通知栏表明App正在后台运行使用第三方推送SDK如极光推送、个推下发新订单提醒作为WebSocket的兜底引导用户进行电池优化白名单设置这在小米、华为等机型上几乎是必做的实话实说哪怕做了以上所有手段依然会有部分机型无法实时收到推送这是国产安卓生态的客观现状。作为一个追求体验的产品至少要做到用户打开App立刻能通过WebSocket同步最新订单状态App在后台时通过第三方推送尽量触达骑手。6. 部署上线与常见问题排查6.1 服务器部署与HTTPS配置开发调试阶段可以用内网穿透工具但正式上线必须有自己的域名和HTTPS证书。微信小程序要求所有请求域名必须是HTTPS并且要在小程序后台配置request合法域名。安卓APP对HTTPS没有硬性要求但为了让用户放心建议也配上。我当时部署用的环境是一台2核4G的云服务器阿里云或腾讯云的活动机就够用、Ubuntu 20.04系统、PHP 8.1、Nginx、MySQL 5.7。Laravel部署有几个关键点项目根目录需要指向public目录也就是Nginx的root配置要改成项目的public路径需要给storage目录和bootstrap/cache目录写权限要开启php-fpm并配置socket通信环境变量文件.env要正确配置特别是APP_KEY如果不生成会导致session和加密功能失效Nginx配置的大致样子server { listen 443 ssl; server_name api.example.com; ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; root /var/www/example/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.1-fpm.sock; } }这里有个细节Laravel的路由重写依赖try_files把所有请求交给index.php如果你用Thinkphp对应的配置是location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } }。如果你用Thinkphp开发但Nginx配置还是Laravel那套路由就会404或者直接下载php文件。6.2 小程序真机调试与安卓联调的那些坑小程序开发中最常见的错误就是“开发版小程序已过期请扫码重新预览”。这其实是开发者工具里的预览版本有效期问题重新点一下预览按钮、扫码就恢复了不是代码问题。但如果你是在安卓手机上用真机调试还要确认手机和电脑连接的是同一个WiFi并且勾选了开发者工具里的“不校验合法域名”否则真机上请求会全部失败。安卓APP联调时如果手机用USB连电脑需要开启开发者选项里的USB调试而且注意部分国产手机USB调试默认关闭需要连续点击版本号才能打开开发者模式。联调过程中如果出现“未能连接到本地服务器”优先检查手机和电脑是不是同一个局域网以及后端接口监听的IP是不是0.0.0.0而不是127.0.0.1。6.3 常见问题排查速查表结合这个项目的实际经验我整理了一份高频问题排查表现象原因解决方法小程序请求一直pending然后超时HTTPS证书配置错误或域名未在小程序后台配置检查证书是否完整在微信公众平台配置request合法域名登录获取不到openid后端AppSecret填错或获取code的接口调用太频繁检查小程序后台的AppSecret确认和服务端一致WebSocket连接总是断开服务器防火墙或Nginx没有配置WebSocket代理Nginx的location里增加proxy_set_header Upgrade和Connection头订单状态错乱没有使用乐观锁或事务给状态更新加上where条件用DB::transaction包住修改支付成功但订单还是待支付回调地址被微信服务器拒绝确保回调地址公网可访问、不要带后缀参数、验证签名逻辑正确安卓收不到推送系统后台限制推送服务使用前台服务第三方推送兜底引导用户加白名单图片上传失败server的upload_max_filesize过小修改php.ini上传大小限制和nginx client_max_body_size骑手抢单报“手慢了”但订单确实没人接Redis锁和数据库更新逻辑不一致检查锁释放时机确保释放锁之后再做状态更新操作6.4 上线运营后的数据观察系统上线跑了一个学期后积累了一些运营数据这里做一些真实分享日均订单量稳定在200单左右高峰期午晚餐时间、期末复习期间能达到400单。订单类型分布大概是取快递占45%、带饭占30%、代买占15%、其他占10%。客单价平均10元含配送费骑手单均收入在6-8元之间一天跑20单的骑手月收入能到3000这个收入在学校里还是有吸引力的。技术层面的观察是2核4G服务器在支撑300并发请求时CPU和内存使用率大约在40%-50%完全够用。MySQL单表订单量到5万条左右后列表查询会出现轻微变慢需要给order_sn、status、rider_id这些字段加索引。Redis的内存占用不大主要存token和订单锁最多几十MB。在功能迭代上用户呼声最高的三个需求分别是聚合多个快递代取把几个快递合成一个订单、跑腿超市代买小店商品直接下单、骑手评价体系。这些都是在基础版本跑通之后可以逐步加上的功能技术上没有难度主要是业务设计需要仔细规划。7. 整个项目踩过的一些坑和心得做这个项目的过程中有几个大坑让我印象非常深刻。第一个是支付金额精度问题。PHP里的浮点数运算在涉及金额时很容易出偏差比如0.10.2并不是精确的0.3。后来所有金额运算统一改用了bcmath函数订单创建、支付回调、退款、提现全部用bccomp、bcadd、bcmul来处理再也没出过金额对不上的问题。第二个是事务嵌套问题。有一次在支付回调里我用了DB::transaction然后在事务里又调用了另一个方法那个方法里又开了一个事务。逻辑上没问题但后来调试发现内层事务如果抛出异常外层事务不会自动回滚导致数据不一致。解决方式是统一处理事务边界内层方法不再开事务改成由外层统一控制。第三个是微信小程序合法的业务域名审核。第一次提交审核时因为首页调用了第三方统计SDK报了一堆“不在合法域名列表中”的警告差点被拒。后来把所有请求都收敛到自己的域名第三方工具要么不用要么通过服务端中转审核就顺利通过了。最后想多分享一个小技巧跑腿系统的测试环境一定要用真实手机来测特别是地址选择、支付流程、消息推送这三块。小程序模拟器和真机差异很大模拟器上正常的支付在真机上可能因为缺少某些配置就失败安卓的推送在模拟器里几乎测不出后台被杀的问题只有真机才能暴露。这个项目做完之后最大的感受是一个看起来简单的校园跑腿平台实际开发下来涉及的知识点覆盖了前端、后端、移动端、数据库、支付、部署、运维等方方面面。如果你能完整走一遍这个流程对软件工程的理解绝对会上一个台阶。后续如果想往深了做还可以加地图轨迹、骑手路径规划、信用分体系、多校区分仓技术架构上都有现成的方案可以借鉴核心业务模型的扩展性在最初设计时就已经预留好了。
