说实话,最开始看到“校园顺路代送平台”这个题目,我第一反应不是代码结构,而是校园里每天都在发生、但一直没人好好解决的一个场景:快递到了驿站,人在宿舍不想下楼;食堂窗口排着长队,室友喊你帮忙带一份;期末复习在图书馆,临时需要回宿舍取笔记本。这些事情单看都很小,小到大家已经习惯用微信群接龙来解决,但群里消息一多,单没人接、东西送错、跑腿的人不靠谱,问题就全冒出来了。
这个项目做的就是把这些零散的“校园顺路需求”搬到微信小程序里:后端用SpringBoot提供接口和数据支撑,前端用微信小程序负责下单、接单、状态跟踪。它的核心不是做一个滴滴式的跑腿大平台,而是把“顺路”两个字做透——谁正好要从A点去B点,就顺手把沿途的代送单接了。对想练手SpringBoot生态、做毕业设计,或者真的想在校内小范围验证一个代送工具的同学来说,这篇内容应该能帮你省不少弯路。
1. “顺路”这个词,才是整个平台的核心
1.1 校园里的代送需求到底长什么样
先说一个我观察到的现象:校园里的代送需求,集中度非常高。驿站、食堂、教学楼、宿舍区,基本就是几个热门点位来回跑。距离说远不远,走路十几分钟,骑车五分钟,但就是因为“懒得跑一趟”,需求就产生了。
这些需求有几个共同点:
- 单笔金额小:一份饭、一个快递,价值不高,用户不愿意付太高的跑腿费。
- 时效要求弱:不像外卖那样必须半小时内到,很多时候只要“今天能送到就行”。
- 信任成本高:把宿舍钥匙、学生证交给一个陌生人,大家心里都会打鼓。
所以,如果按照商业跑腿平台的思路去做,反而会别扭。商业平台靠专职骑手、高密度订单、算法调度来赚钱,校园场景体量撑不起这套模式。真正合适的做法就是“顺路撮合”:不是专门为了一单跑一趟,而是让本来就要路过的人顺手完成。这也是这个项目标题里最值得琢磨的几个字——顺路。
1.2 项目围绕的三种角色与业务闭环
整个平台的角色其实很清楚,一共三种:
| 角色 | 做什么 | 核心诉求 |
|---|---|---|
| 发单同学 | 发布代送需求,写明取件地、送达地、报酬 | 快速有人接单,东西安全送到 |
| 接单同学 | 浏览附近订单,顺路接下,完成配送 | 不绕路、不费事,顺便赚点零花钱 |
| 运营者 | 管理用户、审核订单、处理纠纷 | 平台能跑通,信用体系能沉淀 |
MVP阶段最核心的业务闭环是:发布订单 → 订单进入待接单池 → 顺路同学抢单 → 取件 → 送达 → 双方确认 → 互相评价。这个闭环跑通了,后面的信用分、路线匹配、数据统计都是在这个基础上叠加的。
这里要特别说一个设计决策:MVP不要做在线支付。原因很现实——个人主体小程序无法开通微信支付,涉及资金结算就要企业资质、商户号、分账能力,这些东西对一个校园项目来说太重了。更稳妥的做法是把支付留在线下,平台只做“单子撮合”和“状态记录”,报酬在线下用微信转账或者校园卡结算。这样既避开了支付资质的问题,也让整个项目的技术复杂度下降一大截。
1.3 为什么偏偏选SpringBoot和微信小程序
这个问题经常被问,我的回答也很直接:不是因为它们是最酷的技术,而是因为它们是最合适的选择。
微信小程序的好处是免安装、打开即用,而且校园里微信的渗透率几乎是百分之百,不需要让用户额外下载App。更关键的是,小程序自带微信登录能力,后端可以通过wx.login()拿到用户的openid,天然形成一个稳定的用户身份标识。相比自己搞一套手机号+验证码的注册流程,省事太多了。
SpringBoot的话,Java的生态成熟度摆在那里,网上资料多,遇到问题搜一下基本都有答案。而且如果这是毕业设计或课程项目,SpringBoot + MyBatis-Plus + MySQL这套组合,在答辩时也特别容易讲清楚——分层清晰、链路完整,评委一看就知道你掌握了后端开发的整套流程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端骨架和数据模型:先把地基打正
2.1 三端协作关系与接口约定
这个项目的技术架构不复杂,但协作关系要理清楚。整个系统分三端:
- 微信小程序端:负责展示、下单、接单、状态查看、评价。
- SpringBoot后端:负责业务逻辑、数据存储、鉴权、订单状态流转。
- 管理后台(可选):负责用户管理、订单审核、数据统计,可以用Vue或者直接用SpringBoot模板引擎做。
小程序端和后端之间,走的是标准的RESTful API。因为不涉及Web端,跨域问题几乎不存在,接口统一返回Result<T>格式,结构大概是:
json复制{
"code": 0,
"message": "success",
"data": {}
}
code=0代表成功,非0代表业务错误。这里的设计心得是:不要用HTTP状态码表达业务错误,比如订单已被抢、余额不足这类问题,HTTP状态码统一返回200,通过code字段区分。为什么?因为小程序的wx.request对非2xx状态码会走fail回调,但你希望它在业务层统一处理,而不是在传输层被打断。
2.2 SpringBoot工程搭建:版本选择与核心依赖
版本选择上有一个坑必须提醒:如果你跟着网上的老教程走,很容易踩到SpringBoot 3.x的兼容性问题。3.x要求JDK 17起步,而且不少老的springfox、swagger starter在3.x上直接跑不起来。稳妥的选择是SpringBoot 2.7.x + JDK 8或JDK 11,这套组合最成熟,资料也最全。
核心依赖大致长这样:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
Redis在这套体系里的作用主要不是缓存热点数据,而是干两件事:抢单防并发和用户Token存储。这两个点后面会单独展开。MySQL则是主存储,所有订单、用户、评价数据都落在这里。
2.3 核心数据表的字段设计
数据表不需要设计得很复杂,几张核心表就够了:用户表、订单表、评价表。订单表是最关键的,我列出我认为必不可少的字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| publisher_id | bigint | 发单人ID |
| taker_id | bigint | 接单人ID,初始为空 |
| pickup_address | varchar | 取件地址描述 |
| delivery_address | varchar | 送达地址描述 |
| pickup_lat / pickup_lng | decimal(10,6) | 取件点经纬度 |
| delivery_lat / delivery_lng | decimal(10,6) | 送达点经纬度 |
| delivery_type | tinyint | 代送类型,如快递/餐食/其他 |
| reward | decimal | 酬劳,MVP阶段支持0 |
| order_status | tinyint | 订单状态,见下面状态机 |
| expected_time | datetime | 期望送达时间 |
| created_time | datetime | 创建时间 |
经纬度字段经常被人忽略,但在“附近订单”这个需求里它是最重要的。微信小程序端的接口可以通过wx.getLocation()拿到用户当前坐标,下单时把取件点的经纬度存下来,接单侧就能按距离筛选“顺路”的订单。如果没有经纬度,只靠文本地址做匹配,后面的距离计算和顺路度评分都无从谈起。
用户表则在标准的id、nickname、avatar之外,一定要存openid(唯一索引)和session_token。openid是微信侧的用户唯一标识,session_token是后端自定义签发的登录凭证,小程序每次请求带着这个token来证明身份。
2.4 项目包结构与职责划分
后端项目结构建议如下:
code复制com.campus.delivery
├── controller # 接收HTTP请求,校验参数
├── service # 业务逻辑,订单流转核心在这里
├── mapper # MyBatis-Plus接口
├── entity # 数据库实体
├── dto # 请求/响应的出参入参对象
├── config # Redis、WebSocket、拦截器配置
├── utils # 距离计算、Token生成等工具类
└── common # 统一返回体、业务异常
分层的原则是:Controller只做参数接入,Service做业务判断,Mapper只做数据库交互。这个分层在项目初期会觉得有点啰嗦,但一旦订单状态流转逻辑变复杂,你就知道这么分的价值了——每个模块的职责是单一的,出了问题能快速定位,而不是在一个几百行的Controller里捞针。
3. 登录链路和状态机:业务跑起来的基本盘
3.1 微信登录:wx.login 拿到 code 之后的完整链路
微信登录是小程序项目最容易写混的地方。很多人直接把wx.login()返回的code当成用户凭证来用,这是不对的。完整链路是:
- 小程序端调用
wx.login(),拿到一个临时凭证code,有效期大约5分钟,只能使用一次。 - 小程序把
code通过wx.request发送到后端接口,比如POST /api/auth/login。 - 后端拿着
code,调用微信接口https://api.weixin.qq.com/sns/jscode2session,换取openid和session_key。 - 后端用自己的逻辑生成一个
token(可以是JWT,也可以是随机字符串),把token作为key、openid作为value存入Redis,并设置过期时间。 - 后端把
token返回给小程序,小程序存入wx.setStorageSync('token', token)。 - 后续所有请求,小程序在header里带上
Authorization: Bearer token,后端拦截器解析token并拿到当前用户。
后端核心逻辑大概是这样的:
java复制@PostMapping("/api/auth/login")
public Result<String> login(@RequestBody LoginRequest req) {
// 1. 调用微信code2Session接口
WxSession session = wxService.code2Session(req.getCode());
// 2. 根据openid查用户,不存在则注册
User user = userService.findOrCreate(session.getOpenid());
// 3. 生成自定义token并存入Redis
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(
"login:token:" + token,
user.getId().toString(),
7, TimeUnit.DAYS
);
// 4. 返回token
return Result.success(token);
}
之所以要自己签发token再存Redis,而不是直接把openid放前端,是因为openid是敏感信息,裸奔在前端容易被人滥用。而且引入Redis之后,可以非常方便地实现“退出登录即失效”“封号即踢下线”这类管理操作——只要删掉Redis里对应的key就行。
3.2 订单状态机:哪些状态、谁有权变更
订单表里的order_status字段是整个项目业务逻辑的枢纽。我常用的状态枚举是:
| 状态值 | 含义 | 谁可以变更为下一个状态 |
|---|---|---|
| 0 | 待接单 | 任意顺路用户可抢单 |
| 1 | 已接单(待取件) | 接单人、发单人可取消 |
| 2 | 配送中 | 接单人确认已取件后进入 |
| 3 | 已送达 | 接单人确认送达 |
| 4 | 已完成 | 发单人确认收到后进入 |
| 5 | 已取消 | 发单人、接单人、管理员均可 |
这里最需要注意的设计点:状态变更必须有权限校验。比如“已接单”状态,不是谁都能改成“配送中”的,只有接单人本人才能操作。实现上,Service层每一步状态变更都要校验当前登录用户和订单的taker_id(或publisher_id)是否一致。
另一个实用经验是:不要直接在前端控制按钮的可用状态。前后端都要校验。前端隐藏按钮只是体验优化,后端的接口校验才是安全边界。曾经见过一个项目,前端把取消按钮隐藏了,但接口没校验,前端直接调用取消接口照样能取消订单,这就是典型的安全漏洞。
3.3 抢单防并发:用Redis做一个简单门卫
“抢单”这个动作看似简单,实际是最容易出并发问题的。想象一个场景:一个代送单挂在池子里,酬劳还不错,两个同学同时点了“抢单”,如果不加控制,两个人都可能抢成功——后写的覆盖先写的,用户体验直接崩了。处理方案一般有两层:
第一层:Redis SETNX门卫
java复制// 尝试占用订单,返回true表示抢到了
Boolean gained = redisTemplate.opsForValue()
.setIfAbsent("order:grab:" + orderId, userId.toString(), 30, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(gained)) {
throw new BusinessException("手慢了,订单已被抢走");
}
这个setIfAbsent操作是原子性的,多个请求同时进来,只有一个能设置成功。设置成功的人才有资格进入后面的数据库更新逻辑。
第二层:数据库乐观锁
sql复制UPDATE order_info
SET taker_id = #{userId}, order_status = 1
WHERE id = #{orderId} AND order_status = 0
WHERE order_status = 0这个条件就是乐观锁的核心——更新前再次确认状态还是“待接单”,只有影响行数为1时才算抢单成功。两层叠加下来,即使Redis出问题,数据库层面也不会放行第二个抢单者。
3.4 状态变更通知:轮询还是WebSocket
订单状态变化后,怎么让发单人第一时间知道?两种方案:
- 定时轮询:小程序每隔几秒请求一次订单详情接口,实现简单,但浪费流量,而且通知不及时。
- WebSocket长连接:后端推送状态变化,实时性好,但小程序切后台时连接会断开,需要处理重连。
我的建议是:MVP阶段用轮询就够了,订单接口做10秒以内的轮询,体感上差异不大。如果你想把项目做得更有亮点,可以给SpringBoot加上WebSocket模块,在小程序端用wx.connectSocket维持连接。这里有一个真实存在的坑:热搜词里提到“微信小程序如何监听用户离开小程序”,对应的是onHide和onShow生命周期。用户切走再切回来时,WebSocket连接往往会断掉,必须在onShow里判断连接状态并主动重连,否则订单状态推送就悄悄失效了。
4. 小程序端最容易踩的三个技术点
4.1 附近订单的距离查询:Haversine 公式
“附近订单”功能,核心是距离计算。两个经纬度点之间的距离,不能用简单的勾股定理,因为地球是球面,1度经度在不同纬度对应的实际距离差别很大。标准的做法是用Haversine公式计算球面距离。
SQL层面一个常见的骚操作是直接根据经纬度范围粗筛,再在内存里精算:
sql复制SELECT id, pickup_address, reward,
6371 * 2 * ASIN(
SQRT(
POWER(SIN((#{lat} - pickup_lat) * PI() / 180 / 2), 2) +
COS(#{lat} * PI() / 180) * COS(pickup_lat * PI() / 180) *
POWER(SIN((#{lng} - pickup_lng) * PI() / 180 / 2), 2)
)
) AS distance
FROM order_info
WHERE order_status = 0
AND pickup_lat BETWEEN #{minLat} AND #{maxLat}
AND pickup_lng BETWEEN #{minLng} AND #{maxLng}
HAVING distance < 3
ORDER BY distance
这里先用经纬度范围把候选集限制在一个矩形框内(比如±0.05度,大约5公里),避免全表扫描,再对候选集做精确距离计算和过滤。对小规模校园项目,这个方案完全够用。如果将来订单量大了,可以再考虑利用Redis的GEO模块,或者引入geohash,但那是后话。
4.2 列表加载更多:分页与 onReachBottom 的搭配
“微信小程序页面列表加载更多”这个话题在热搜词里出现不是没道理的,很多新手写分页都会踩坑。常见错误是:触底时重复请求、翻页参数不重置、数据请求和渲染竞态。
我的做法是维护一个统一的分页状态:
javascript复制Page({
data: {
list: [],
pageNum: 0,
pageSize: 10,
hasMore: true,
isLoading: false
},
onReachBottom() {
if (!this.data.hasMore || this.data.isLoading) return;
this.loadMore();
},
loadMore() {
this.setData({ isLoading: true });
const pageNum = this.data.pageNum + 1;
request({
url: '/api/order/nearby',
data: { pageNum, pageSize: this.data.pageSize, lat, lng }
}).then(res => {
const newList = res.data.list;
this.setData({
list: this.data.list.concat(newList),
pageNum,
hasMore: newList.length === this.data.pageSize,
isLoading: false
});
});
},
// 切换筛选条件时重置列表
refreshList() {
this.setData({ list: [], pageNum: 0, hasMore: true, isLoading: false });
this.loadMore();
}
});
关键细节有两个。一是用isLoading做并发拦截,防止onReachBottom在请求还没回来时连续触发;二是hasMore的判断要用“返回条数是否等于pageSize”,而不是“返回条数是否大于0”,否则最后一次刚好满页时就会漏掉下一页。
4.3 请求封装:统一注入token和错误码处理
微信小程序没有axios,只有wx.request,如果每个页面都写一遍完整请求逻辑,代码会非常臃肿。建议封装一个统一的request方法,在拦截器层面做三件事:注入token、统一处理业务错误码、统一处理登录失效。
javascript复制function request({ url, method = 'GET', data = {} }) {
return new Promise((resolve, reject) => {
wx.request({
url: baseUrl + url,
method,
data,
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + wx.getStorageSync('token')
},
success(res) {
if (res.data.code === 0) {
resolve(res.data.data);
} else if (res.data.code === 401) {
wx.removeStorageSync('token');
wx.navigateTo({ url: '/pages/login/login' });
reject(new Error('登录失效'));
} else {
wx.showToast({ title: res.data.message, icon: 'none' });
reject(new Error(res.data.message));
}
},
fail(err) {
wx.showToast({ title: '网络异常', icon: 'none' });
reject(err);
}
});
});
}
这里有一个热搜词提到“springboot 签名认证”——如果你后端的拦截器实现了token签名校验,前端传的Authorization头会在SpringBoot的拦截器里被切面解析。我建议把Token的校验放到一个HandlerInterceptor里,统一对/**生效,少量注解如@PassToken可以放行登录接口,这样权限逻辑不会散落到各个Controller。
5. 实名认证、审核上线与后续演进
5.1 身份证识别与用户实名:OCR思路与隐私红线
校园代送平台有一个绕不开的问题:怎么让发单人放心把东西交给接单人。微信群接龙之所以乱,很大原因就是接单人没有任何身份标识。所以,实名认证是这个项目从“玩具”走向“可用”的关键一步。
实现路径是这样的:小程序端调用wx.chooseMedia拍摄身份证正反面,后端接到图片后,调用第三方OCR接口识别出姓名和身份证号,再用身份证号调用实名核验接口,确认用户填写的姓名和身份证号一致。做这一步时,数据库只能保存脱敏后的姓名(比如张*豪)和加密存储的身份证号,身份证原图应当即刻销毁,不该入库。这是隐私红线,不能碰。
这里要特别注意合规:如果只是做毕业设计或校园内部体验,识别身份证信息属于敏感个人信息处理,需要在小程序隐私协议里明确告知用户,并且说明信息用途。建议MVP阶段不要强制要求实名,而是用“手机号授权 + 校园邮箱后缀校验”这种更低门槛的方式过渡。
5.2 小程序发布前必须处理的事:域名HTTPS、类目与年审
等到代码写得差不多了,准备把小程序发给同学试用,或者正式提审,有几个现实问题比写代码更磨人:
- 合法域名校验:小程序里所有
wx.request的域名,必须在小程序后台配置为HTTPS合法域名,并且域名要完成ICP备案。开发阶段可以在开发者工具里勾选“不校验合法域名”,但体验版和正式版绕不过去。 - 个人主体限制:个人主体注册的小程序,有些类目是无法选择的,比如部分涉及配送、交易的服务类目需要企业主体或者特定资质。校园场景内,最常见的方案是用学校或创业团队的企业主体来注册。
- 体验版发放:开发者工具里上传代码后,到小程序后台版本管理页面把对应版本设为体验版,把测试者的微信号加到体验成员列表,对方扫描体验版二维码就可以试用。这也是热搜词里“怎么发给其他人试用收集试用反馈”的答案,实践上就是这套流程。
- 年审费用:小程序每年需要年审,不过年审期间功能正常,但如果拖的时间太久,会被限制新版本发布。这个时间点要提前在项目规划里预留。
5.3 完全可以再往下做的方向:路线顺路度、信用分、运单池
如果上面的核心功能都跑通了,项目其实已经基本成型。但“顺路代送”这个方向还有几个很好的演进点:
路线顺路度计算:现在简单的距离筛选只是“取件地点近”,真正的顺路要考虑接单人的行动路线。可以在用户端增加“发布行程”功能,接单人标注“我从宿舍去教学楼”,后端通过路径规划接口计算这个行程与订单取送点的重合度,重合度高的订单优先推荐。这一步会让“顺路”从概念变成真正的算法。
信用分体系:每一次成功接单、准时送达、获得好评,都累加信用分。信用分高的接单人能看见的订单范围更广、可同时持有的订单数更多。这是平台从“工具”走向“社区”的关键设计。
运单池与调度:当订单量和接单人数量都上来了,可以增加“批量接单”能力——接单人一次最多接3单同方向的订单,后台帮他把取送顺序优化好。这才是校园配送效率真正质变的点。
最后分享一点个人的实操体会
这个项目做完,我最大的体会是:校园顺路代送这类工具型项目,难点从来不在某一个技术点,而在于把“顺路”这个模糊的概念变成一个可执行的状态机和一套清晰的数据结构。你最开始想的可能是“我要做一个跑腿平台”,但真正动起手来,思考的应该是“订单从发布到完成到底经过哪些状态”“谁有权限改变这些状态”“附近订单怎么算距离”“抢单怎么保证不冲突”——把这些想清楚了,代码其实水到渠成。
如果你准备复刻一个类似的项目,我的建议是先别急着把支付、地图、实名、信用分全部铺满,先把“发单—抢单—送达—确认”这条最核心的链路做到无bug,再逐步往外面加东西。项目能跑起来,被身边同学真实用起来,比堆了多少技术栈有价值得多。
