1. 项目概述与选题思路
计算机毕业设计选SpringBoot外卖系统,几乎是每个做Java方向的学生都躲不过的经典题目。这个题既能体现前后端基本功,又能带出数据库设计、缓存、定时任务、接口安全这些知识点,答辩的时候可讲的东西非常多。我当年做毕业设计选了“基于SpringBoot的智能化餐饮外卖订购系统”,实际开发下来发现,真正难的不是CRUD,而是订单状态怎么流转、超时单怎么处理、库存怎么防止超卖、前后端怎么顺畅联调。这篇就把我完整做这套网上点餐配送管理平台的过程,以及踩过的坑,全部梳理出来。
先明确一下这个题目到底要做什么。一套完整的外卖系统,按角色拆开来看是这样:用户端负责注册登录、浏览菜品、加购物车、下单支付、追踪订单配送状态;商家端负责菜品上下架、修改价格库存、接单出餐;骑手端负责查看待配送订单、抢单/接单、更新配送进度;管理后台则处理用户/商家/骑手的审核与整体数据统计。很多同学把这个题目做小了,只做了用户点餐和商家后台,缺少骑手配送这条线,答辩时很容易被老师追问“配送状态谁来维护”。所以我在设计初期就把配送环节纳入核心流程,这也是“智能化”三个字落地的关键点之一。
1.1 为什么选外卖系统作为毕设课题
选这个题有个很现实的好处:业务链路长,但每个环节都不过于复杂。从下单到支付、接单、配送、完成,每一个状态切换都有明确的业务含义,老师一看就觉得这是个“完整系统”。同时它足够贴近生活,哪怕老师不是搞软件的,也能理解外卖是怎么运作的,沟通成本低。
从技术训练的角度看,外卖系统几乎能把Java后端主流技术都串起来。SpringBoot框架做基础骨架,MyBatis-Plus操作数据库,Redis做缓存和分布式锁,定时任务处理超时未支付订单,JWT做登录鉴权,再配合Vue写前端页面。这套组合覆盖了校招面试和毕业设计的高频考点,做完之后你对SpringBoot的自动装配原理、事务传播机制、缓存一致性这些概念也会有更落地的理解。
1.2 项目最终要做成什么样
我给自己定的目标是做一个“能跑通全流程”的闭环系统,而不是一堆页面堆在一起。最终交付的东西包括:一个基于SpringBoot的后端工程、一个Vue3前端工程、MySQL数据库脚本、项目部署文档和答辩PPT。演示的时候从用户注册开始,下单、模拟支付、商家接单、骑手配送、用户确认收货,一条完整链路走完,再展示后台的销售数据统计页面,整套演示大概五分钟就能讲清楚。
这里我给后来者的建议是:先用自己的话把整个流程画出来。画一张泳道图,把用户、商家、骑手、系统四个参与者之间的交互画清楚,后面写代码、建表、写文档都会轻松很多。不要在需求还没理清的时候就去建SpringBoot工程,那只会越写越乱。
2. 技术选型与整体架构
技术选型这块,我的核心原则是“能用稳定版本绝不用最新版本,能用常见组合绝不搞花活”。毕业设计的核心是展示你对技术原理的理解,而不是追新版本。下面是我最终定的方案。
2.1 SpringBoot版本选择的坑
SpringBoot版本这个坑,很多同学都踩过,尤其是“springboot版本太高”导致的连环问题。我刚开始为了图新鲜,创建工程时选了SpringBoot 3.x,结果发现它强制要求JDK 17及以上,而且原来的javax包全部改名成jakarta,网上大量教程里的老代码直接报错,Lombok版本、MyBatis-Plus版本都要跟着升级。折腾了一晚上,最后还是老老实实换回SpringBoot 2.7.x + JDK 8的组合。
如果你的电脑已经装了JDK 8,项目就直接用SpringBoot 2.7.x,这是目前兼容性最好的稳定组合。如果学校机房是JDK 17,那你可以用SpringBoot 3.x,但要注意把MyBatis-Plus、Lombok、Swagger这些依赖版本对齐。我的建议是优先参考SpringBoot官网的版本说明,不要只看某篇博客说“最新的最好”。毕业设计求稳,跑起来比什么都强。
2.2 单体还是前后端分离
外卖系统这种规模,没必要微服务,单体应用完全够用。但我依然推荐把代码分层做清楚:Controller层只管接收参数和返回结果,Service层写业务逻辑,Mapper层操作数据库,再单独建一个config包管理配置。这样的好处是答辩的时候你可以很自然地说“我使用了分层架构,实现了代码解耦”。
前端我选了Vue3 + Element Plus,通过npm run build把打包后的dist目录放进SpringBoot的src/main/resources/static里。也就是热搜词里说的“vue打包放进springboot中”。这样做之后,整个系统只有一个端口,部署时直接java -jar运行jar包就能访问,省去配置Nginx的麻烦。不过这么做有个前提,前端路由要用hash模式,如果用history模式,刷新页面会404,需要额外写一个路由回退Controller,这个细节我后面专门讲。
2.3 数据库与缓存设计
数据库用的MySQL 8.0,持久层框架选MyBatis-Plus。选择MyBatis-Plus主要是看中它的代码生成器和条件构造器,写CRUD效率特别高,能把时间留到核心业务逻辑上。缓存方面引入Redis,主要用于三块:存登录token、存验证码、做库存预扣和防重复提交的分布式锁。
这里特别说一下SpringBoot的自动装配原理。每次启动项目,SpringBoot都会根据starter依赖自动配置对应的Bean,比如MyBatis-Plus、RedisTemplate都是自动装配进来的。理解这一点,你会明白为什么只要引入了依赖、配好了application.yml就能直接用这些组件。如果在运行时发现某个Bean没有初始化,排查方向就是它的自动配置类是否满足条件,比如Redis没启动、配置项缺失、类路径缺少某个类,都会导致自动装配不生效。这个原理不需要死记硬背,面试或答辩时用自己的话讲一遍,老师就觉得你不是只会用框架。
3. 数据库建模与核心表设计
数据库设计是这个项目的灵魂。我见过很多同学外卖系统做完,代码能力没问题,但数据库表设计一塌糊涂,字段类型混乱、没有索引、父子关系指向错误。表格设计得好,业务实现自然顺畅;设计得差,后面全是补丁代码。
3.1 用户、商家、骑手三端角色怎么建模
我的做法是建了user、merchant、rider三张表分别管理,没有把三类角色塞进一张统一的user表。这样虽然表多了一些,但每个角色的字段差异很大,分开更清晰,后面扩展也方便。
user表核心字段包括id、openid(如果接微信登录)、nickname、phone、password、avatar、status。merchant表在基本信息之外,还要有store_name、store_address、latitude、longitude,因为配送派单要基于距离计算,经纬度必须存。rider表核心字段是id、name、phone、status(在线/休息/配送中)、current_latitude、current_longitude。三个角色各自登录,用的是同一套登录接口,只是登录成功后返回的角色类型不同。
这里有个细节:不要用普通字段装饰数据库里的大文本。比如商品图片地址,存的就是一个相对路径字符串,不要存base64,否则数据库会迅速膨胀。头像和菜品图全部走文件上传,服务器磁盘保存文件,数据库存URL路径。
3.2 订单与配送状态机设计
订单表是整个系统的中心。字段设计时一定要把状态、取消原因、退款状态这类业务字段想全。我的orders表关键字段如下。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(32) | 订单号,唯一 |
| user_id | bigint | 下单用户 |
| merchant_id | bigint | 所属商家 |
| rider_id | bigint | 接单骑手,初始为空 |
| status | tinyint | 订单状态 |
| total_amount | decimal(10,2) | 订单总金额 |
| pay_amount | decimal(10,2) | 实付金额 |
| address_id | bigint | 配送地址快照 |
| remark | varchar(255) | 用户备注 |
| pay_time | datetime | 支付时间 |
| delivery_time | datetime | 送达时间 |
| create_time | datetime | 创建时间 |
订单状态我用数字来表示,实际代码中定义一个枚举类:0待支付、1已支付待接单、2已接单配送中、3已完成、4已取消、5退款中、6已退款。每一次状态变更都只允许从当前状态跳转到规定的下一个状态,不能用“随便改status字段”的粗暴方式,否则会出现已取消的订单还在配送这种逻辑灾难。
3.3 购物车、菜品、地址等辅助表细节
菜品表dish要存store_id、category_id、name、price、image、stock、status,其中status用来做上下架,status为0时前端不展示,库存扣减也要绕过已下架的菜品。购物车表cart字段有user_id、dish_id、quantity,不需要单个商品金额,因为最后的金额以下单时的数据库价格为准。
地址表user_address要存id、user_id、receiver_name、receiver_phone、province、city、district、detail_address、is_default。下单时不能直接引用地址表,必须把收货人姓名、电话、详细地址复制到orders表的几个冗余字段里,这是为了避免用户后来修改地址导致历史订单信息不准。
订单明细表order_item也非常重要,它的字段包括order_id、dish_id、dish_name、dish_image、price、quantity。dish_name、dish_image、price三列是从菜品表冗余过来的快照字段。为什么要冗余?因为商家改菜价、改图片甚至删除菜品之后,你仍然希望历史订单能看到当时的商品信息。直接用外键关联dish表的话,一删菜全乱套,所以快照字段是必须的。
4. 核心功能实现与难点拆解
架构和表设计做完后,进入真正的编码阶段。这一节我挑几个最核心、最容易被老师追问的业务实现来拆解。
4.1 点餐下单的事务与锁
下单流程大概是:用户前端点击去结算,后端校验库存、扣减库存、生成订单、生成明细、清空购物车。这里最怕的是两个问题:一是库存扣成负数,二是用户重复点击按钮产生两条一模一样的订单。
解决超卖,最简单的办法是数据库层面的原子操作。MyBatis-Plus里写一个update方法:UPDATE dish SET stock = stock - 1 WHERE id = ? AND stock > 0,返回值是受影响行数,如果返回0说明库存不足。这种方式比先select再update安全得多,不用额外加悲观锁。在Redis里做前置库存预扣也行,但毕设项目直接走数据库扣减已经够了。
防重复提交我用了两种手段。前端在点击下单后立刻置灰按钮,同时后端用Redis的SETNX实现简单幂等:下单前先以“order_lock:用户ID”为key尝试设置值,设置成功才继续下单,下单完成后删除锁。这样就算前端按钮被绕过,后端也能挡住重复请求。核心思路和分布式锁类似,但实现成本低很多,适合单体应用。
@Override @Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateDTO dto) { String lockKey = "order_lock:" + dto.getUserId(); Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS); if (Boolean.FALSE.equals(locked)) { throw new BizException("订单处理中,请勿重复提交"); } try { // 1. 校验地址、购物车、商家 // 2. 遍历购物车,逐件扣减库存 // 3. 生成订单号,保存orders // 4. 保存order_item明细 // 5. 清空购物车 return orderId; } finally { redisTemplate.delete(lockKey); } }事务注解一定要注意rollbackFor=Exception.class。默认情况下Spring声明式事务只回滚RuntimeException,如果你在事务里抛了一个自定义的普通Exception,它不会回滚,数据就乱了。我建议所有Service层方法都统一用rollbackFor={Exception.class}。
4.2 订单状态流转与定时任务
订单状态流转的核心不是直接改status,而是用“条件更新”来确保状态合法。比如用户取消待支付订单,SQL就是UPDATE orders SET status=4 WHERE id=? AND status=0,受影响行数为1才代表取消成功。如果订单已经支付过了,这个更新就不生效,从根上杜绝了错误状态覆盖。
超时未支付订单是外卖系统躲不开的需求。用户下单后如果不支付,不能让它一直占着库存。我在项目里用了SpringBoot定时任务来处理:@EnableScheduling开启,@Scheduled(cron = "0 */1 * * * ?")每分钟执行一次扫描,把创建时间超过15分钟且状态为0的订单自动取消,同时回补库存。
@Component public class OrderTimeoutTask { @Scheduled(cron = "0 */1 * * * ?") public void cancelExpiredOrders() { List<Order> expiredOrders = orderMapper.selectExpiredOrders(15); for (Order order : expiredOrders) { int updateCount = orderMapper.cancelOrder(order.getId()); if (updateCount > 0) { // 回补库存 restoreStock(order.getId()); } } } }这个方案在单机部署下完全没问题,但如果将来系统部署了多个节点,定时任务会在每个节点都执行一遍,就会重复处理订单。简单的解法是使用Redis实现任务锁,抢到锁的节点才执行。毕设答辩时能主动说出这个隐患和方案,是很加分的点。
4.3 配送派单逻辑
派单逻辑我实现得比较朴素,但效果稳定。当商家点击接单后,系统从rider表里筛选出status为1(在线)的骑手,然后计算骑手当前位置到商家的距离,取距离最近的骑手发送配送推送。距离计算用经纬度Haversine公式,单条记录级别的计算量非常小,不需要引入地图SDK。
骑手接单后在骑手端点击“取餐”,订单状态从2变3,再点击“送达”,状态变3变4,用户端同步看到配送进度。如果骑手长时间不接单,系统会自动把订单重新派给下一位在线骑手,这个机制我用了一个延迟任务:商家接单5分钟后,如果订单还没有骑手接,就把orderId重新加入派单队列。
这块如果想再“智能”一点,可以做成“骑手自主抢单”,也就是把所有待配送订单按距离倒序展示给骑手,骑手手动接单。我最终选择了系统派单,因为流程演示更直观:用户下单、商家接单、系统派单、骑手接单、配送中、完成,整个链路一气呵成。
4.4 支付回调与订单一致性
真实接入微信支付或支付宝支付,需要商户号、证书等资质,毕设阶段通常办不下来。我采用的是模拟支付方案:用户点击去支付,后端直接调用一个payService.mockPay(orderNo)方法,模拟支付回调成功后更新订单状态。
但即使是模拟支付,我也把回调接口的幂等性做得很严格。支付回调里不能写“只要收到回调就更新status”,因为网络超时会导致回调重发。我每次处理回调都执行条件更新:UPDATE orders SET status=1, pay_time=NOW() WHERE order_no=? AND status=0。如果更新成功,说明这次回调是有效且第一次的;如果更新失败,说明订单已经被处理过,直接忽略。
支付环节还有个细节:订单金额的计算必须全部在后端完成。前端传过来的totalAmount不能信,用户改动了前端金额参数再下单,极有可能出现支付0.01元的订单。我在createOrder时从数据库查询菜品价格、计算总价,前端传的金额只用来做展示,不参与任何后端计算。
5. 前端页面与联调要点
后端写得再漂亮,前端页面丑也是硬伤。外卖系统的前端我是用Vue3 + Element Plus做的,没有花太多时间在花哨动画上,重点是信息展示清晰、操作路径顺畅。
5.1 vue打包放进springboot中
“vue打包放进springboot中”这个热搜词,我猜是很多同学卡在实际部署环节。说穿了其实很简单:前端工程里执行npm run build,生成dist目录,然后把dist里的内容整体复制到SpringBoot的src/main/resources/static目录,重新打jar包,访问localhost:8080就能直接看到页面。
但有两个容易翻车的点。第一,前端的接口请求路径如果在开发环境写的是localhost:8080/api,打包后也要保持这个相对路径,不要直接把开发环境的绝对地址写死进去。我在Vue工程里配置了环境变量,开发环境用Vite的proxy代理转发/api到后端,生产环境直接访问同源地址/api。第二,如果用了vue-router的history模式,刷新一个子路由页面会404,因为SpringBoot找不到对应的Controller。解决办法有两个:改router使用hash模式,或者写一个转发方法,把非静态资源的请求转发到index.html。我选择直接用hash模式,因为实现成本最低,对毕设展示没有任何影响。
5.2 接口设计与联调经验
接口设计我采用的是RESTful风格,统一返回Result 结构:code、message、data。后端写了一个GlobalExceptionHandler,用@RestControllerAdvice统一捕获业务异常和参数校验异常,任何位置抛出BizException,都会返回统一格式的错误信息。这样前端处理错误时只需要判断code是否为200,非常省事。
登录鉴权用的是JWT,用户登录成功后会返回token和角色信息,前端把token存在localStorage里,每次请求在请求拦截器中带上Authorization头。后端写了一个拦截器,配置在自定义拦截器中排除登录接口,其余接口统一校验token。这里要注意,SpringBoot默认只拦截controller请求,不会拦截静态资源,但是Vue打包后的静态资源在resources/static里,走SpringBoot静态资源处理器,不经过拦截器,所以不会出现token失效导致页面加载不了的情况。
联调过程中我最推荐的是Knife4j,也就是增强版Swagger文档。SpringBoot集成Knife4j之后,接口文档自动生成,前端可以直接在页面上测试接口。省去了手工维护接口文档的功夫,答辩演示时也能打开文档页面给老师看,显得项目很规范。
6. 常见问题与排查实录
这个部分是我把开发过程中踩过的坑和网上同学问得比较多的问题合并整理出来的,基本都是血泪教训。
| 问题 | 现象 | 解决方案 |
|---|---|---|
| SpringBoot版本太高 | 编译报错,javax不存在 | 换成SpringBoot 2.7.x配JDK8,或统一升级到JDK17 |
| 接口返回中文乱码 | 页面显示问号 | 在WebMvcConfig中配置StringHttpMessageConverter的UTF-8编码 |
| 文件上传后图片404 | 重启jar图片就没了 | 把上传文件保存到jar包外的磁盘目录,不要放resources |
| 订单表数据量变大查询慢 | 后台列表秒开变秒卡 | 给order_no、user_id、merchant_id、status建联合索引 |
| 定时任务重复执行 | 订单被重复取消 | 增加Redis任务锁,只有抢到锁的节点执行 |
| Vue打包后刷新404 | 子页面刷新白屏 | vue-router使用hash模式或编写fallback转发 |
| 数据库连接失败 | 启动报Connection refused | 检查MySQL版本、端口、时区配置,加connectTimeout参数 |
6.1 SpringBoot版本太高的问题再展开
前面提过版本问题,这里再展开说一个细节:如果你非要用SpringBoot 3.x,最麻烦的不只是JDK版本,而是第三方starter的兼容性。MyBatis-Plus当时适配SpringBoot 3的版本号是3.5.3+,Lombok得换新版,Knife4j也要用OpenAPI3版本的依赖。如果某个依赖官网明确写着不支持SpringBoot 3,你就不要硬凑。我见过有同学把SpringBoot降级到2.7后,一堆依赖冲突不治而愈。顺带说一句,不管用什么版本,启动类上标着的SpringBootApplication注解下面,都是由EnableAutoConfiguration这个元注解触发的自动装配。所谓“xxx不管用”,八成不是这个注解失效了,而是某个自动配置类的条件不满足。
6.2 高并发下单导致库存超卖
我的系统峰值量不大,但答辩时老师非常喜欢问“如果多个人同时下单买最后一份菜品怎么办”。我当时的回答分了三层:第一层,数据库扣减时使用UPDATE ... WHERE stock > 0,保证不会扣成负数;第二层,在Redis里维护一个菜品库存key,下单提前用DECR命令做预扣,库存不足直接返回;第三层,如果真的要做极高并发,再用消息队列串行化创建订单请求。虽然毕设没真正上消息队列,但这个回答本身就展示了思路的深度。
6.3 前后端联调时跨域问题
本地开发时前端跑在8080,后端跑在5173,默认存在跨域。我用的是Vite代理,配置一个/dev请求转发到后端地址,这样浏览器看起来是相对路径。如果你是前后端单独部署,就需要后端开启CORS跨域配置。我建议能走代理尽量走代理,因为明面上配置CORS有时候还会因为预检请求、自定义Header等原因出各种幺蛾子。代理方式对毕设演示最友好,前后端打包到一起后,跨域问题自然不存在。
6.4 金额计算用Float还是BigDecimal
这个坑我必须要强调:凡是涉及金额的字段,数据库decimal(10,2),Java实体用BigDecimal,绝对不能使用float或double。float和double在计算机中是二进制浮点数,0.1 + 0.2并不等于0.3,累计下来订单总金额就可能出现0.07这种奇葩小数。用BigDecimal进行金额计算时,注意要使用scale设置小数位并使用half-up舍入模式。
7. 项目亮点包装与答辩准备
毕设做完到答辩之间,其实还有很关键的一步:把项目里“普通”的部分讲出价值。
7.1 如何把普通外卖系统讲出亮点
很多同学觉得自己做的系统不就是网上随处可见的CRUD吗?但同样的功能,不同的组织和表达,讲解效果完全不同。我给自己总结了几条主线:
第一条主线是订单状态机。老师问“你项目最复杂的地方在哪”,你就说整个订单从支付、接单、取餐、配送到完成存在完整的状态机约束,每一次状态变更都是条件更新,从机制上避免非法流转。第二条主线是超时关单与库存回补。系统有定时任务自动取消超过15分钟未支付的订单,并且回补库存,保证菜品库存实时准确。第三条主线是配送调度。系统根据骑手位置与商家距离自动匹配最近骑手,并且有超时转派机制,不是写死订单给某个骑手。
如果你还想拔高一点,可以聊聊热点词“springboot整合flink”的思路。也就是引入流式计算,对实时订单数据进行统计和分析,比如每分钟各店铺销售额、热门菜品排行榜。注意这里讲思路就够,不需要真的在毕设里集成Flink,除非你有把握把它跑通。答辩时抛出一句“我了解过SpringBoot整合Flink的实时计算方案,后续可以从这个方向扩展”,已经能让老师知道你具备技术视野。
7.2 答辩前必做的几个准备动作
第一,把核心表的ER图画出来,能够边说表结构边讲业务。第二,准备好几个接口的调用链,比如下单调用链、支付回调调用链、配送状态更新调用链,每一个链路能画出来。第三,准备一台能跑起来的电脑,数据库、Redis、后端、前端全部一键启动,不要等到答辩现场才手忙脚乱地配环境。第四,多问自己几个“为什么”——为什么用Redis缓存、为什么订单状态用条件更新、为什么金额用BigDecimal、为什么用hash模式路由。这些“为什么”比功能本身更能证明你是真的做了项目。
7.3 我做这个项目的一些实在感受
回头再看这个SpringBoot外卖系统,我最大的体会是:毕业设计考察的不是项目有多炫,而是你有没有把最基础的技术点踩扎实。你不需要真的接支付、不需要搞微服务、不需要上Kafka,只要把用户端、商家端、骑手端、后台这条线做成完整闭环,把一个订单从创建到完成的全生命周期管理清晰,就已经超过大部分同学了。
最后分享一个小技巧:写订单号的时候不要只写时间戳加自增数字,我记得自己加了一个用户ID后四位加随机字符串再加时间戳,既保证唯一,又方便定位问题。类似的细节还有很多,比如在Redis里给token设置过期时间、把菜品状态和库存放在一个接口里更新、把定时任务的时间可配置化而不是写死在注解里。这些细节单个看都很小,组合起来就是一个老师挑不出明显毛病、自己维护起来也不痛苦的项目。做毕设的过程就像修房子,骨架搭稳了,水电通了,表面再刷点漆,整个作品就能立得住。