去年选毕设题目的时候,我给自己挖了一个坑,最后做出来的是“基于uniapp的快递e站协同外卖配送系统”。说白了就是把快递驿站代取代寄的需求,塞进外卖骑手的配送流程里:用户在小程序里下单一单“代取快递”,骑手在送外卖的路上顺路去驿站扫码取件,最后送到用户手中。前端用uniapp一套代码跑微信小程序和H5,后端自己用Spring Boot写接口,MySQL存数据,整条链路完全跑通。
这篇文章不写官方项目申报书那种正确的废话,只讲我在做这个计算机毕设过程中的真实选题逻辑、技术选型、数据库怎么拆、接口怎么设计、以及踩过的坑。如果你最近也在挑毕设题目,或者已经选了类似的东西却不知道从哪下手,这篇内容应该能给你省下很多瞎琢磨的时间。
1. 项目选题与整体定位
1.1 这个题目到底在解决什么问题
我一开始的直觉是,快递驿站和外卖配送是两个不同场景,硬凑在一起会不会很别扭。但去我们学校快递驿站门口蹲了几天之后,我发现这个问题是真实存在的:中午取件的人排长队,有些人只是取一个小快递,却要等十几分钟。另一边,外卖骑手送完午高峰之后会有零散的空闲时间,或者有些订单配送方向刚好经过驿站。把这些“代取快递”的需求发到骑手端,让骑手在配送路径上“顺便接一单”,对用户省时间,对骑手增加收入,对驿站减轻压力,这就是系统核心价值。
在毕设题目里,这类系统通常叫“协同配送”或“众包配送”。和纯外卖平台相比,这个题目的新意在于订单不只是“商家→用户”,而是“驿站→用户”加“外卖点→用户”混合流转。所以后端要处理的不是一套简单下单流程,而是快递单状态和配送订单状态的联动,这正好是计算机专业学生展示系统设计能力的地方。
1.2 题目的复杂度和边界控制
选毕设题目有个优先级:可完成 > 能演示 > 有深度 > 显得高级。这个项目的规模属于“一个人在三到四周内能做完、但是又不能纯靠复制粘贴糊弄过去”的程度。前端需要用户端、骑手端、驿站端、管理端四个角色入口;后端需要处理订单状态机、登录鉴权、位置计算、扫码出库;数据库至少要六张表以上。这个工作量和本科毕设的要求匹配度是合适的。
为了把范围讲清楚,我当时在开题报告里做了一张简单的功能矩阵表,后续编码和答辩也基本照着这个表走:
| 端 | 核心功能 | 说明 |
|---|---|---|
| 用户端 | 注册登录、发布代取代送订单、在线支付或到付选择、订单跟踪、取消订单 | 小程序为主,H5也能跑 |
| 骑手端 | 抢单大厅、查看顺路度、接单、取件、送达确认、收入记录 | 核心是地图和状态流转 |
| 驿站端 | 快递入库、扫码出库、快递单绑定、异常记录 | 偏轻量后台,不需要花哨界面 |
| 管理端 | 用户管理、骑手审核、订单统计、配送数据趋势 | 用表格和图表展示 |
把四个角色一列出来,整个项目立刻有说服力了,而且每个端都能拿出来单独演示,答辩现场不容易冷场。
1.3 为什么选uniapp而不是原生开发或纯Web
选uniapp最大的理由是跨端复用。我只需要写一套Vue代码,就能在微信小程序、H5和安卓App上运行。做一个毕设项目,如果给用户端做小程序、给骑手端做App、给管理端做Web,那工作量直接翻好几倍,三个月都未必能打磨完。uniapp + Vue3 + uview-plus是我验证过比较顺手的组合,uview-plus是uview的Vue3版本,组件该有的都有,表单、弹窗、导航、时间选择器基本不用自己写。
后端我选了Spring Boot加MyBatis Plus。之前也纠结过要不要用uniapp官方的uniCloud云开发,因为云数据库和云函数确实能省掉服务器。但考虑再三还是没用,原因是云开发把很多逻辑黑盒化了,答辩老师问“你的接口怎么做鉴权”“数据库表结构怎么设计的”,你要么回答不上来,要么只能秀一套IDE操作。自己写Spring Boot,从Controller到Service到Mapper全在代码里,每个环节都能讲出设计依据。如果你后端基础偏弱,MyBatis Plus可以帮你少写大量重复SQL,这是我给动手能力一般的同学的建议。
2. 核心流程与数据库设计
2.1 订单状态机:让每一单都有明确去处
这个系统里最容易被忽视、也最容易翻车的,是订单状态管理。用户下单后,订单可能被骑手抢、驿站确认、配送中、送达、取消、异常,如果不在数据库层面和代码层面一起约束,最终会出现“用户看订单已经取消了,骑手还在送”这种极度影响演示效果的事故。
我给订单定义了这样几个状态,并且在数据库里用TINYINT存数字:
| 状态值 | 状态名 | 含义 |
|---|---|---|
| 0 | CREATED | 刚创建,等待骑手接单 |
| 1 | ACCEPTED | 骑手已接单,等待取件 |
| 2 | PICKED_UP | 驿站已出库,骑手正在配送 |
| 3 | DELIVERING | 骑手已取到商品,途中(与2可合并) |
| 4 | FINISHED | 用户已确认或系统自动确认完成 |
| 5 | CANCELED | 用户取消/超时取消 |
| 6 | ABNORMAL | 订单异常,如包裹丢失 |
具体落地时,我建了一张order_log表,专门记录订单的每一次状态变化,包括从哪个状态变成哪个状态、是谁操作的、操作时间是什么。这张表看似不起眼,但答辩时老师问“你的系统怎么追溯问题”,我直接说状态日志,然后打开页面展示给老师看,效果远比干讲要好。
2.2 核心数据表:快递单和配送单怎么拆
如果只把快递信息和订单信息塞进一张表,后期绝对会乱。我分了5张核心表:用户表、快递包裹表、驿站表、配送订单表、状态日志表。其中难点在于如何关联“订单”和“包裹”。
一个外卖订单可以没有快递包裹,直接就是商家送餐;一个快递代取代寄订单,用户可能同时有多个包裹要取。所以我没有把包裹信息直接写进订单表,而是单独建一张parcel表,再用delivery_order里的order_id和parcel_id建立关联。这样设计的好处是,一个订单包含多个包裹时,只需要在包裹表里标记order_id,不会产生数据冗余。
下面是我当时建delivery_order表的简化版本:
CREATE TABLE `delivery_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号,前台展示用', `user_id` bigint NOT NULL COMMENT '下单用户ID', `rider_id` bigint DEFAULT NULL COMMENT '接单骑手ID', `order_type` tinyint DEFAULT '0' COMMENT '0-快递代取, 1-外卖配送', `status` tinyint DEFAULT '0' COMMENT '状态: 0待接单,1待取件,2配送中,3已完成,4取消', `pickup_point` varchar(255) DEFAULT NULL COMMENT '取件点文案(驿站地址或商家地址)', `pickup_lat` decimal(10,6) DEFAULT NULL, `pickup_lng` decimal(10,6) DEFAULT NULL, `delivery_address` varchar(255) DEFAULT NULL COMMENT '送达地址文案', `delivery_lat` decimal(10,6) DEFAULT NULL, `delivery_lng` decimal(10,6) DEFAULT NULL, `fee` decimal(10,2) DEFAULT '0.00' COMMENT '配送费', `expect_time` datetime DEFAULT NULL COMMENT '期望送达时间', `remark` varchar(500) DEFAULT NULL COMMENT '备注', `create_time` datetime DEFAULT NULL, `finish_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;包裹表:
CREATE TABLE `parcel` ( `id` bigint NOT NULL AUTO_INCREMENT, `tracking_no` varchar(64) NOT NULL COMMENT '快递单号', `station_id` bigint NOT NULL COMMENT '所属驿站ID', `user_id` bigint DEFAULT NULL COMMENT '绑定用户ID', `order_id` bigint DEFAULT NULL COMMENT '关联的配送订单ID,为空表示未下单', `status` tinyint DEFAULT '0' COMMENT '0-已入库,1-待取件,2-已出库,3-异常', `pickup_code` varchar(20) DEFAULT NULL COMMENT '取件码', `arrive_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;我在订单表里冗余了取件点的经纬度和地址文案,而不是通过驿站表去join。原因是骑手端列表要频繁展示“从哪取、送到哪”,如果每次都实时join驿站表,接口响应会变慢,代码也更啰嗦。对毕设项目来说,一点冗余能换来简单直接的开发体验,是值得的。
2.3 协同派单逻辑:不装高深,但要能自圆其说
协同配送的“协同”两字是题目亮点,也是最容易被答辩老师追问的地方。我一开始想用最短路径算法去算骑手配送路线,后来发现数据源根本搞不定:没有完整路网、没有实时路况、没有拥堵指数,硬算出来的路径也只是“看起来高科技”的纸面方案。所以我最终用的是“顺路度评分 + 人工抢单”的组合。
具体做法是:骑手打开抢单大厅,服务端根据骑手当前经纬度,计算当前订单的取件点、送达点与骑手已有任务路径的直线距离差,生成一个0到100分的顺路度分数,默认按分数从高到低排序。骑手自己决定接不接,后台不给强制派单。这样既体现了“协同”的思想,又不至于把项目复杂度拖垮。
如果老师接着问“你这个顺路度没有考虑实际道路,会不会不准”,你的回答思路是:毕设阶段保留扩展点,后续可以用地图SDK的路径规划接口替换直线距离,已经预留了算法接口。这就够了,没有人会要求一个本科毕设真的跑通全局最优派单。
3. uniapp前端实现与关键模块
3.1 HBuilderX建项目与uview-plus接入细节
第一个坑就是UI库版本。网上搜到“uview”教程,很多都是基于Vue2的,而新创建的uniapp项目默认是Vue3,直接装uview会出现组件无法渲染、控制台报错一堆的问题。正确做法是在HBuilderX的插件市场里搜“uview-plus”,这名字看起来像山寨,但它是正儿八经适配Vue3的UI库。
项目创建我选的模板是“默认模板(Vue3)”,然后在main.js里注册组件库:
import uviewPlus from 'uview-plus'; import { createSSRApp } from 'vue'; export function createApp() { const app = createSSRApp(App); app.use(uviewPlus); return { app }; }同时还需要在uni.scss里引入主题变量,并在pages.json里配置easycom规则。这里有个经验:不要漏掉@import 'uview-plus/index.scss',否则按钮、输入框的颜色会很奇怪。配置好easycom之后,页面里可以直接用u-button、u-empty这类组件,不需要手动import,能省掉很多重复代码。
3.2 四个角色的页面规划和地图选点
用户端页面我做得比较常规:登录页、首页(推荐快递单和附近驿站)、创建订单页、订单详情页、个人中心页。真正容易出问题的是骑手端。骑手端需要一个“任务大厅页面”,进入后要申请定位权限,然后拉取附近待接单订单。这里地图组件用uniapp内置的map,配腾讯地图或高德地图的key。manifest.json里需要填写对应平台的key,否则真机上地图黑屏。
创建订单时,用户要从地图选点或者手动输入地址。我做了个“地图选点”页面,把picker和map组件结合。以下是核心逻辑的简写:
<template> <map id="pickerMap" :latitude="lat" :longitude="lng" @tap="onTapMap" /> </template> <script setup> const lat = ref(39.9); const lng = ref(116.4); function onTapMap(e) { lat.value = e.detail.latitude; lng.value = e.detail.longitude; // 调用腾讯地图位置解析接口得到文字地址 } </script>注意:uniapp的map组件点击事件返回的坐标是gcj02坐标系,存储到后端时一定要标清楚坐标系。我之前没在意,结果小程序模拟器正常,安卓真机上用户选的点和实际送达地址偏移了几百米,排查了半天才想起来是坐标系的锅。
3.3 订单状态同步:轮询比WebSocket划算
用户端要实时看到订单状态从“待接单”变到“配送中”,很多人第一反应上WebSocket,我觉得对毕设来说没必要。WebSocket维护成本和心跳逻辑麻烦,而且答辩时很难解释清楚服务器端连接管理。我用的是轮询,10秒一次,订单详情页onShow时启动定时器,onHide时清除,接口返回的状态变化足够满足演示效果。
let timer = null; function startPolling(orderId) { stopPolling(); timer = setInterval(async () => { const res = await getOrderDetail(orderId); if (res.data.status === 3) { // 已送达,停止轮询 stopPolling(); uni.showToast({ title: '订单已送达' }); } }, 10000); } function stopPolling() { if (timer) clearInterval(timer); }这个方法虽然没什么技术含量,但很稳定,不会因为弱网情况下连接断开就丢状态。对真实业务量不大的校园场景来说,10秒级延迟用户根本感知不到。这也是我后来跟同学强调的:项目里所有技术选型都要考虑场景,轮询不是丢人,乱上WebSocket才是给自己找罪受。
4. 后端接口设计与实现
4.1 统一返回结构和登录鉴权
早期写接口时我每个Controller返回类型都不一样,有的返回Map,有的直接返回实体,前端拿到数据后处理逻辑非常混乱。后来我把所有接口改成统一返回Result<T>:
{ "code": 200, "msg": "success", "data": {} }判断成功就看code是不是200,前端封装一个request.js统一拦截,非200直接弹出错误提示。登录鉴权用的是token方案,用户登录成功后生成一个随机UUID把userId存到数据库token表,之后所有请求都在header里带Authorization: token。后端加一个拦截器,对/api路径下的接口做校验,放行名单是登录、注册和地图配置接口。这个方案不用依赖Redis,避免多一个中间件还要跟老师解释为什么用Redis。
4.2 用户下单到骑手送达的完整调用链
最能展示项目深度的,是把这个流程串成一条逻辑闭环。
第一步用户POST /api/order/create提交订单,参数包括orderType、stationId、deliveryAddress、deliveryLat、deliveryLng、fee。后端先生成订单号,比如用时间戳加随机数,然后创建订单记录,并把对应包裹表的order_id更新为该订单,包裹状态设为“待取件”。这个动作必须放在同一个事务里,否则会出现订单创建成功但包裹没绑定上。
第二步骑手GET /api/rider/order/list拉取待接单列表。后端遍历待接单订单,计算当前骑手位置到取件点的距离以及取件点距离终点的长度,生成顺路度,按倒序返回。这里不用太精确,用球面距离公式足够。
第三步骑手POST /api/order/accept接单。接口里不能只做update delivery_order set rider_id = ? where id = ?,必须加一个状态条件,防止两个骑手同时抢同一单:
UPDATE delivery_order SET rider_id = #{riderId}, status = 1 WHERE id = #{orderId} AND status = 0返回影响行数,如果影响行数为0说明已经被别人抢了,提示“手慢了”。这套乐观锁思路虽然简单,但能完美回答并发场景下的数据一致性问题。
第四步驿站工作人员扫包裹上的取件码,调用POST /api/station/confirm,后端先校验包裹的order_id是否等于当前订单,校验通过后把包裹状态改为“已出库”,订单状态改为“配送中”。
第五步骑手到用户楼下点送达,调用POST /api/order/finish,后端修改订单状态为“已完成”,同时写一条状态日志。到这里,整条链路闭环。
4.3 管理端统计和性能思考
管理端我计划了四个统计维度:每日订单量、骑手完成单量、各时段订单分布、驿站包裹库存情况。实现用SQL聚合就够了:
SELECT DATE(create_time) AS day, COUNT(*) AS order_count FROM delivery_order GROUP BY DATE(create_time) ORDER BY day DESC LIMIT 7如果数据量大了再加索引,毕设阶段完全不需要考虑分库分表。但要注意的一点是,轮询接口最好建一个联合索引(status, create_time),我实测没有索引的时候,小程序端三个页面同时轮询,本机MySQL CPU直接飙到30%,加了索引之后降到3%左右。这算是优化接口性能最简单的实操,也能在答辩时提一句,显得你对索引有概念。
5. 常见问题与排查技巧实录
5.1 H5端跨域和本地联调
后端默认跑在8080端口,uniapp运行到浏览器访问的是HBuilderX内置服务器的端口,两边端口不同,必然跨域。我一开始在后端写了@CrossOrigin注解,但只能解决单个Controller,后来直接加了一个全局配置类,统一放行跨域请求。核心代码:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }加完之后H5端请求后端一切正常,小程序端因为不存在跨域问题,所以不用额外处理。有的同学喜欢在HBuilderX里配置proxy代理,我也试过,但只对web开发环境有效,而且配置起来容易出错,直接后端CORS是最快路径。
5.2 小程序真机预览时的域名问题
小程序上线或真机预览时,request接口域名必须是HTTPS且在小程序后台配置合法域名。毕设阶段没有备案域名和证书是很正常的。我在开发调试时用两个办法过渡:一个是在微信开发者工具右上角点击“详情”,勾选“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”,这样本地开发完全不受影响;另一个是把Spring Boot项目部署到云服务器,用Nginx配HTTPS,但这一套对时间紧张的同学来说成本略高。如果只为了校内演示,勾选不校验合法域名就够了。
5.3 地图定位漂移和坐标系不一致
这个问题在安卓真机上特别明显。uniapp内置map组件使用的坐标和腾讯位置服务都是GCJ02(火星坐标系),而部分手机返回的定位结果是WGS84原始坐标,两者相差数百米。我当时的排查思路是:先在echarts里把问题订单取件点和实际驿站位置打出来,发现取件点拖到马路对面了,再看数据库存的经纬度是从地图选点拿到的还是从腾讯SDK逆地址解析拿到的,最后统一在入库前做一次坐标转换。如果后端拿到的坐标来自第三方API,也一定要确认对方返回的坐标系。
5.4 答辩老师最可能追问的几个点
第一个是“为什么不用WebSocket”,回答重点是10秒轮询满足实时性要求,实现简单可靠。第二个是“如果用户取消订单,已经取件的骑手怎么处理”,我的做法是:取件前用户可无条件取消;取件后不能直接取消,只能联系驿站或客服变为异常订单,骑手把包裹送回驿站。第三个是“你怎么保证数据安全”,可以提密码加盐哈希、token鉴权、SQL预编译防注入。这些问题提前想好,答辩基本不会冷场。
6. 源码获取与二次开发建议
6.1 拿到源码后怎么跑通
很多同学拿到源码第一反应是先看页面,其实正确顺序应该是先配环境,再初始化数据库。本项目需要的环境非常常规:JDK1.8及以上、Maven 3.6+、MySQL 5.7或8.0、HBuilderX、微信开发者工具。运行步骤很简单:
- 创建一个空的数据库,执行项目里提供的
sql初始化脚本,里面包含建表语句和初始数据。 - 修改后端
application.yml,把数据库用户名密码改成自己的。 - 启动Spring Boot后端,控制台出现端口号就说明成功。
- 用HBuilderX打开uniapp前端项目,在
utils/request.js里把baseURL改成http://localhost:8080/api。 - 运行到微信开发者工具或浏览器,用初始化脚本里的测试账号登录。
有个常见问题:数据库脚本里导入的中文乱码,解决方法是连接数据库时指定characterEncoding=utf8,建表语句本身也统一用utf8mb4。这个搞不定的话,后面所有页面都会看到乱码提示,直接影响第一印象。
6.2 从毕设升级到实习项目的几个方向
如果做完这个毕设还有余力,想往作品集方向打磨,我建议优先做三件事。一是接入微信支付和uniPush推送,把在线支付和订单状态通知做成真正的业务闭环;二是增加多驿站分站支持,让一个驿站的数据不由全局管理端直接改,而是由对应驿站员工管理;三是引入地图路径规划SDK,把顺路度评分从“直线距离”升级为“实际骑行距离”。这些方向不用一次全做完,选一个做深,放到简历里都能碾压大多数只会复制管理系统项目的候选人。
最后分享一个我做这个项目时最深刻的体会:做这种多角色系统,最大的坑不是技术难点,而是你自己以为的“优化思路”在拖后腿。我一开始花了两天设计派单算法,结果发现连测试数据都构造不好,后来果断砍掉,用抢单大厅加顺路度排序,项目反而顺利走通。毕设的核心是先让它完整地跑起来,再让它有点亮眼的设计,最后才轮到“算法优化”这种锦上添花的事。如果你正在被某个功能卡住,想一想能不能用更朴素的方法替代,能演示、能讲清楚,就已经赢了大多数同学。