拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

校园跑腿外卖一体化完整源码:订单、支付、抢单全链路拆解

校园跑腿外卖一体化完整源码:订单、支付、抢单全链路拆解

大学校园里最真实的痛点,大概就是饭点食堂排队、外卖进不了校门、快递驿站排长龙。把这些需求揉在一起做的"校园跑腿外卖一体化"系统,这几年在各高校里几乎成了刚需。我最近把一套完整跑通的源码重新整理了一遍,从下单、抢单、支付到结算,前端小程序、骑手端、管理后台全都齐活。这篇就把整个项目的架构设计、核心源码逻辑、部署上线流程和踩过的坑完整拆出来,给正在做课程设计、毕业设计或者真打算在校园里做点小生意的同学一份可以直接抄作业的参考。

这套系统的核心价值在于,它不是简单复刻一个美团外卖,而是针对校园场景重新设计了配送模型:外卖配送、快递代取、超市代买、文件同送,全部跑在一条订单链路上。源码层面用Spring Boot做后端,小程序端基于UniApp可以同时编译到微信和支付宝,管理后台是Vue3全家桶。下面我把整个设计思路和源码实现层层拆开讲。

1. 项目定位与技术选型:先把场景和需求拿捏住

1.1 校园场景拆解:这活儿为什么不能直接套用美团模式

很多人一开始的想法是"照着外卖平台抄一套就行",真正动手才发现校园环境有大量边界条件。商业外卖的核心是商家出餐加骑手配送,而校园跑腿是"人找人"的服务,需求种类极其碎片化。代取快递需要看驿站营业时间,代买零食需要知道超市货架位置,帮带食堂饭菜需要精确到窗口。这些需求在传统外卖系统里根本没有对应的数据模型。

校园的另一个特征是地理范围小但人流密度高。一个校区可能就两三条主干道,但宿舍楼、教学楼、食堂、驿站分散在不同区域,配送距离通常不会超过两公里,但高峰期订单会集中在极短的午饭和晚饭时段爆发。这种"短时高并发"对订单系统的压力点和商业外卖完全不同,抢单机制、运力调度、配送费计算都要重新设计。

还有一层是信任问题。校园跑腿的骑手基本都是本校学生,用户和骑手之间存在身份关联。系统必须有一套校园身份认证机制,注册时要校验学号、姓名匹配,订单完成后能互相评价。这些都决定了源码设计不能照抄通用电商系统,得从底层表结构就开始针对校园场景建模。

1.2 技术栈选型:这套源码用了什么,为什么这么选

我对这套系统的技术选型核心原则是:不追新、只求稳、源码要能看懂能改。后端用Spring Boot 2.7 + MyBatis-Plus,这个组合的资料多、上手快,出了问题随便一搜就有解决方案。数据库用MySQL 8.0,订单这类强事务数据必须走关系型数据库。缓存和分布式锁用Redis,抢单场景离了它根本扛不住。小程序端用UniApp,一套代码编译到微信小程序和支付宝小程序,省去双端维护成本。管理后台用Vue3 + Element Plus,表格表单类页面开发效率极高。

这套选型在实际开发中有几个很实际的好处。MyBatis-Plus的单表CRUD几乎不用写SQL,能让精力集中在订单流转和并发控制上。UniApp对校园这类小团队项目特别友好,不需要维护两套前端代码,改一个bug双端同时生效。Spring Boot的自动配置大大减少了配置文件量,打包成JAR丢到服务器就能跑,部署门槛低。

模块技术选型选型理由
后端服务Spring Boot 2.7 + MyBatis-Plus生态成熟,开发效率高
数据库MySQL 8.0事务保证,订单数据可靠
缓存/锁Redis 6.x抢单原子操作,热点数据缓存
小程序端UniApp + Vue3一套代码多端发布
管理后台Vue3 + Element Plus后台交互快速成型
对象存储腾讯云COS用户头像、反馈图片存储
支付微信支付V3 + 支付宝校园用户主流支付方式

这里有个经验:支付千万要提前申请好商户号,我见过好几个项目代码全写完了,卡在支付资质申请上拖了两周。学生主体可以申请微信支付,但需要学校相关证明,建议提前和辅导员沟通。

1.3 整体架构:一条订单链路把五个端串起来

源码整体是经典的前后端分离单体架构,加上简单的前端工程。单体架构在校园这种日单量几百到几千的场景下完全够用,没必要一上来就搞微服务,那只会增加部署和运维负担。

系统的核心参与者有五个角色:用户C端小程序、骑手端小程序、商家端小程序、管理后台、后端服务。用户下单后订单进入待抢池,骑手端实时刷单抢单,商家端处理出餐,管理后台做审核和运营。这五个端通过后端REST API和WebSocket消息推送协同工作。

订单数据流大致是:小程序端调POST /api/order/create创建订单,后端校验用户认证和基础参数后写库,再通过RabbitMQ或Redis发布订阅模式通知骑手端有新人下单。骑手抢单成功后,系统推送状态变更给用户。支付环节走微信支付,前端发起下单支付,后端接收异步回调更新订单状态。整个链路的状态变更都以数据库事务为准,缓存和推送都只是辅助加速。

2. 数据库设计与订单状态机的源头设计

2.1 核心表结构与字段说明

这套源码里最值得抄的就是表设计。一共21张表,核心的几张我单独说一下。用户表user除了基础账号信息,还存了学号、学校ID、校园认证状态,骑手额外有骑手审核状态、接单开关、今日单数等字段。学校表college存校区坐标和配送范围边界,用经纬度加半径圈定。

订单主表orders是整张数据模型的心脏,字段我列一下核心的:order_no业务订单号、user_id下单人、order_type订单类型(1外卖 2快递 3代买 4同送)、pickup_address取货地址、delivery_address送达地址、goods_desc物品描述、amount商品金额、delivery_fee配送费、payment_amount实付金额、status订单状态、runner_id骑手ID、shop_id关联商家。快递代取还要单独存快递公司、取件码等字段,我单独拉了一张express_info表存。

商家表和菜单表比较常规,值得注意是商家表要加merchant_type区分食堂窗口、校外商户、超市便利店。跑腿服务没有货架,所以没有库存表,但加了price_rule表管理不同距离区间的配送费阶梯价。钱包表wallet管理用户余额和骑手收入,结算单独走settlement_record表,不直接改主钱包,避免算错账。

表名核心字段作用
useropenid, student_no, auth_status, role用户与骑手身份
collegename, longitude, latitude, radius校区配送范围
ordersorder_no, order_type, status, runner_id订单主状态流转
express_infopickup_code, express_company, cabinet_no快递代取信息
price_rulestart_distance, end_distance, price配送费阶梯计算
settlement_recordorder_no, amount, status, type骑手钱包流水

这个表结构我踩过一个坑:一开始把送达地址只存了一个字符串,后来发现配送费计算和订单统计都需要地址的经纬度。建议从第一版就把地址拆两列,一列address_text展示用,一列address_location存经纬度JSON。

2.2 订单状态机的设计与流转控制

订单状态是全系统最容易出bug的地方,设计阶段就要把状态机定死,不能在业务代码里随便改状态。这套源码的状态机如下:

待支付(0) -> 待接单(1) -> 已接单(2) -> 配送中(3) -> 已完成(4),取消则进入已取消(5)。退款单状态下单独有个refund_status字段,不混在主状态里。

待支付到待接单这一步是支付回调触发,不能在前端点完支付就立刻改状态,必须等微信异步通知到后端。已接单到配送中需要骑手点击"已取货"。配送中到已完成需要用户或骑手点击确认送达,这里要做距离校验,骑手必须进入送达地点的半径范围内才能触发确认按钮,防止虚假送达。

我还用一张order_status_log表把所有状态变更记下来,每改一次状态就插一条日志。这个表在事后排查纠纷、统计订单时长时非常有用。很多跑腿平台后来出了问题对不上账,都是因为没做状态日志。这块属于源码里看起来不起眼、但生产环境必不可少的设计。

2.3 配送费计算与距离判定

配送费不能直接用直线距离算,校园里建筑密集,用户从宿舍到驿站的实际步行距离和直线距离差很多。源码里用的是腾讯地图的步行路径距离API,根据起终点经纬度拿实际步行距离,再套用price_rule表中的阶梯规则。

配送费规则我设计成了三段:0到500米收2元基础配送费,500米到1公里收3元,超过1公里每500米加1元,封顶6元。这个价格在校园场景里用户接受度较高,骑手也愿意跑。阶梯规则不要写死在代码里,放到数据库表里,运营同学随时可以调价,改完不用发版。

距离判定在取货阶段也用到。用户下单时系统计算骑手当前位置到取货点的距离,超过1.5公里的单子不推送给该骑手,缩小抢单范围,避免骑手接到太远的单配送时间过长。这块距离计算同样走地图API,实测下来比用直线距离判断准确得多。

3. 关键源码实现:从下单到送达的完整链路

3.1 下单流程与运力校验

下单接口的核心逻辑不只是插入一条orders记录,还要做几个前置校验:用户是否完成校园认证、当前订单是否在配送范围内、用户有没有未完成订单。这部分用Spring AOP做一个拦截注解,统一校验,避免每个接口重复写。

配送范围内校验用的是圆形范围判断:计算下单经纬度和学校圆心之间的距离,超过半径就被拦截。

public boolean checkInScope(double lon1, double lat1, College college) { double distance = getDistance(lon1, lat1, college.getLongitude(), college.getLatitude()); return distance <= college.getRadius(); } private double getDistance(double lon1, double lat1, double lon2, double lat2) { double radLat1 = lat1 * Math.PI / 180.0; double radLat2 = lat2 * Math.PI / 180.0; double a = radLat1 - radLat2; double b = lon1 * Math.PI / 180.0 - lon2 * Math.PI / 180.0; double s = 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2))); s = s * 6378.137; return s * 1000; }

下单完成后要立刻给骑手端发推送,源码用的Redis的发布订阅模式。订单创建后把订单号发布到channel:new_order频道,骑手端通过WebSocket订阅这个频道,实时更新待抢单列表。比轮询数据库高效得多,也不会对数据库造成压力。

3.2 抢单锁的实现:Redis Lua 与数据库状态双保险

抢单是整个系统并发压力最大的点,也是最容易出事故的环节。两个骑手同时抢一个单,如果只靠业务代码里先查再改,必然出现超卖。我当时在设计抢单逻辑时直接上了双保险:Redis分布式锁加数据库乐观锁。

先看数据库这层,抢单本质是一个条件更新:把订单状态从待接单改成已接单,同时锁定抢单骑手ID。用MyBatis-Plus写一个UpdateWrapper,条件带上status = 1,如果影响行数为0,说明订单已经被别人抢走。

boolean grabbed = updateOrderStatus(orderId, runnerId); // SQL: UPDATE orders SET status = 2, runner_id = ?, // grab_time = NOW() // WHERE id = ? AND status = 1 if (!grabbed) { throw new BizException("手慢了,订单已被抢走"); }

数据库这层已经能保证不超卖,但我还加了一层Redis锁做过滤,防止大量骑手同时打到数据库造成压力。Redis这里用的不是简单的setnx,而是Lua脚本保证原子性,避免锁过期和误删问题。

-- KEYS[1] = order:lock:{orderId} -- ARGV[1] = runnerId if redis.call('exists', KEYS[1]) == 1 then return 0 end redis.call('set', KEYS[1], ARGV[1], 'EX', 30) return 1

Redis锁拿到后执行数据库更新,更新成功才释放锁。锁的过期时间设30秒,正常情况下抢单操作几毫秒就完成,所以设太短会导致订单还没更新完锁就过期被别的骑手二次获取;设太长万一持有锁的线程崩溃,订单锁死30秒又会造成体验问题。30秒是我权衡后的经验值,实际操作中几乎没出过问题。

3.3 支付回调的幂等处理

支付这部分是源码里最需要谨慎的地方。微信支付V3的异步回调,同一个支付结果可能推送多次,回调处理如果不做幂等,订单状态会被重复更新,对账也会出问题。

回调的验签流程一定要完整走:拿到回调头部的Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce,用商户平台下载的微信支付平台证书验签,验签通过后再解密报文。这个步骤不能在测试阶段偷懒,我见过直接把验签注释掉上线的情况,结果被伪造支付回调刷了大量订单。

验签通过后,真正的业务逻辑只有两步:先查订单当前状态,如果已经是已支付就直接返回SUCCESS,不再走后续逻辑;如果是待支付状态,才执行更新状态、增加钱包流水、通知骑手端。

PayOrder order = payOrderMapper.selectByOutTradeNo(outTradeNo); if (PayStatus.PAID.equals(order.getStatus())) { return "SUCCESS"; // 幂等返回 } // 只有在未支付状态下才更新 order.setStatus(PayStatus.PAID); orderMapper.updateById(order); // 同步订单状态,通知骑手端有新单可抢 orderService.afterPaid(order.getOrderNo());

回调接口还有一个关键点:接口返回给微信的应答必须是纯文本SUCCESS或FAIL,不能返回JSON格式的"success",微信那边不识别,会一直重试。这个坑我调试了整整一下午才发现是返回格式的问题。

3.4 消息推送与骑手定位的轻量实现

消息推送在源码里用的是WebSocket而非第三方推送SDK,这样省去额外费用,并且校园场景下前后端连接比较稳定。骑手端小程序连接WebSocket后订阅个人通道,订单创建、订单取消、用户催单都会实时推给对应骑手。

定位功能用微信小程序自带的地图组件,骑手APP端每隔10秒上报一次经纬度。用户端能看到骑手动线,这是用户体验的重要组成部分。后端接收位置上报的接口只做两件事:更新redis里骑手位置缓存,把最近位置写入location_log表。骑手接近取货点和送达点时会触发距离判断,做"到达提醒"。

真正要注意的是websocket线程安全。一个订单可能同时有用户端、骑手端多个连接在监听,推送时必须保证同一个事件按顺序发出,否则状态错乱。源码里的做法是每个订单维护一个队列,事件按序入队再推送,实测下来消息不乱序。

4. 部署上线与性能调优:源码跑起来只是第一步

4.1 本地开发环境与一键启动配置

源码拿到手先在本地跑通,别急着上服务器。后端环境需要JDK 1.8以上、Maven 3.6、MySQL 8.0、Redis 6.x。数据库脚本在doc/sql目录下,直接导入即可。启动前要改application.yml里的数据库账号密码和Redis地址。

小程序端需要安装HBuilderX,导入uniapp目录后运行到微信开发者工具。需要注意的是微信小程序必须在开发者工具里配置合法域名,本地调试可以勾选"不校验合法域名",但上线前必须换成正式的HTTPS域名。另外appid要换成自己的,不能直接依赖源码里的测试号。

这里有个小经验:本地调试时可以启动一个Nginx把后端接口反向代理到http://localhost:8080,同时配好HTTPS证书,这样小程序端的请求路径就不用改来改去,提交上线时直接切到服务器IP就行。

4.2 服务器部署与HTTPS / 小程序合法域名

部署方案是数据库、Redis、后端JAR分别部署在云服务器上,用Docker Compose管理,配合Nginx做反向代理。小程序要求所有请求域名必须是HTTPS,证书一个域名就能覆盖API和后端静态资源。

Docker Compose编排文件主要包含三个容器:MySQL、Redis、后端服务。数据库和Redis用数据卷持久化,后端服务镜像在启动时自动拉取最新JAR。

services: mysql: image: mysql:8.0 restart: always ports: - "3306:3306" volumes: - ./mysql-data:/var/lib/mysql environment: MYSQL_ROOT_PASSWORD: your_password MYSQL_DATABASE: campus_errand redis: image: redis:6.0 restart: always ports: - "6379:6379" backend: build: . restart: always ports: - "8080:8080" depends_on: - mysql - redis environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/campus_errand SPRING_REDIS_HOST: redis

部署上线后第一步不要急着做功能测试,先把健康检查接口调通。Spring Boot的actuator的/actuator/health返回UP说明数据库、Redis部分连接成功,再走一遍微信支付的测试下单流程。

4.3 索引优化和缓存策略实战

订单表是核心表,数据量上来之后查询性能直接决定系统能不能用。orders表上我建了联合索引(user_id, status)和(status, create_time)。骑手抢单页的列表查询走的是(status, create_time)索引,用户历史订单走(user_id, status)索引。

缓存方面,不是所有数据都要缓存。菜品菜单这类读多写少的接口做了Redis缓存,过期时间设5分钟,保证数据变更能在较短时间内生效。用户信息缓存30分钟,登录状态缓存2小时。

有个经验一定要分享:订单数据千万不要整单缓存,订单状态是实时变的数据,一缓存就等着出各种脏读问题。我做的是把订单列表页的摘要信息缓存,进入详情页时实时查库。这样既减轻数据库压力,又保证最关键的状态信息是最新的。

4.4 安全与风控:防刷单、防参数篡改

校园跑腿系统最怕的是刷单。用户端创建订单接口做了频率限制,同一个用户10秒内最多创建5次订单,超过就拒绝。这里用Redis计数器实现,每单唯一校验码绑定用户和订单。

支付金额这块必须防篡改,前端传过来的paymentAmount只能作为展示参考,后端要重新计算一遍——商品金额从数据库商家菜单里取,配送费从配送距离实时算,两个值相加才是真实应付金额。前后端金额不一致直接报错。这个校验是支付安全的基础防线。

骑手结算安全要防的是刷单行为。发的结算规则是订单完成30分钟后,跑腿费自动从冻结钱包转成可提现金额,30分钟足够取消纠纷处理。同一台设备频繁切换账号也会被风控系统捕捉,直接封禁设备。这套逻辑代码量不大,但是对平台资金安全特别重要。

5. 常见坑点与二次开发避坑指南

5.1 高频问题速查表

问题现象根因解决方案
支付回调一直失败没有返回SUCCESS文本接口返回纯文本SUCCESS,别返回JSON
骑手抢单提示失败未开启Redis持久化配置appendonly yes,重启不丢锁数据
订单创建成功但骑手端看不到WebSocket连接断开未感知心跳机制,30秒没心跳主动重连
用户支付成功但订单状态未变回调验签失败检查平台证书序列号是否配置正确
确认送达按钮一直置灰坐标偏差导致距离校验失败送达半径从50米放宽到100米
小程序请求被拦截没有配置合法域名在小程序后台配置request合法域名

最有意思的坑是确认送达按钮的问题。调测的时候发现测试机定位飘了100多米,用户明明到了驿站但一直点不了确认送达。后来在送达判断时加了判断条件:用户坐标或骑手坐标有一方在校验半径内就算送达,不再要求双方都在半径内,问题就解决了。

5.2 源码二次开发的三条经验

第一,改订单状态前先查状态日志。任何针对orders表的update操作,写代码前先想想这个状态变更应该由哪个角色、哪个业务动作触发,禁止在多处代码里写死状态流转逻辑,否则后续查问题会非常难。

第二,加新功能优先考虑在现有表上扩展字段,不要轻易动订单主状态机。比如跑腿代买需要小费功能,只用在orders表加一个tip_amount字段,不要新搞一套状态。状态机改动牵一发动全身,改不好就是连环bug。

第三,学会看日志定位问题。这套系统几乎所有接口都打了一条info级日志,里面带着订单号和关键参数。线上排查问题的时候,先tail -f日志,通过订单号能搜出完整请求链路。日志一定要打印关键业务参数,不要图省事只打印空泛的success。

5.3 二次开发的扩展方向

这套跑腿外卖一体化源码能扩展的空间非常大。现在单校区跑,可以考虑多校区模式——college表已经是独立设计,只要扩一个校区管理员角色就能切换管理后台的校区维度。

另一个方向是拼单和拼团。同一个宿舍楼的人可以拼单点同一家食堂,订单合并配送,骑手一趟能送好几个单,同时降低用户的配送成本。这个功能需要加一个拼单组表和合并支付逻辑,但订单主链路不用大改。

再就是反作弊升级。当前的风控逻辑比较简单,如果想更稳,可以引入设备指纹和位置轨迹分析,识别异常频繁的换单、异常短时间的刷单行为。这个方向适合做毕设的同学展现实力,能加分不少。

这套项目做完调试上线,我最大的体会是:做校园类项目,源码技术难点其实就集中在并发控制和状态管理上,真正绕不开的是对场景的理解度。骑手为什么抢单慢、用户为什么催单、商家为什么出餐慢,这些运营层面的问题最后都会转化成技术方案——缓存策略、推送时机、审核规则。源码能跑只是及格,跑得稳定、跑完还能让运营同学用起来顺手,这才是做完一个校园项目的真正标准。最后再提醒一句:数据库和Redis的备份策略一定要提前设好,我的服务器曾经因为磁盘满了导致MySQL写不进去,当时正在做结算,好在一键备份脚本派上了用场,才没让账目对不上。这个血的教训,希望你们不要经历第二次。

返回列表