
做毕业设计选“基于Spring Boot的外卖点餐管理系统”这个题目的人这两年我见到特别多。原因也简单业务场景贴近日常生活功能边界清晰从用户下单到商家出餐的整个链路都能在一个项目里闭环体现而且Spring Boot Vue这套技术栈在就业市场上属于主流配置做完之后简历上有的写答辩时也有东西可讲。但选题容易做好难。我前前后后带过不少做这个题目的同学发现大多数人卡住的点其实高度一致数据库表设计不合理、订单状态流转混乱、JWT鉴权拦截器放行配置出错、前端打包资源不知道怎么塞进后端工程、部署到服务器上图片路径又失效。这篇文章就围绕这些真实痛点把我认为一个合格的Spring Boot外卖点餐管理系统应该怎么设计、怎么写、怎么部署完完整整拆开讲一遍。1. 从选题到技术选型为什么这套组合最稳妥1.1 外卖点餐系统的业务边界与核心价值做项目之前先把业务边界划清楚。外卖点餐系统往大了做可以接支付、接地图、接骑手调度但作为毕业设计或练手项目核心闭环只需要覆盖两条主线。用户侧的主线是注册登录 → 浏览菜品 → 加入购物车 → 提交订单 → 模拟支付 → 查看订单状态。商家侧的主线是登录后台 → 管理分类和菜品 → 处理订单接单/完成。管理员的线可以合并到商家后台也可以单独拉一个后台管理用户和菜品审核。把这三条线走通系统在功能上就是一个合格的外卖平台雏形。这里有个容易被忽视的设计要点订单状态是整个系统的灵魂。状态机设计得好后面写业务逻辑会非常顺畅状态机混乱代码会越写越难受。我建议把订单状态收敛为以下六种状态含义触发动作0待支付用户提交订单1已支付/待接单用户模拟支付成功2制作中商家接单3配送中商家出餐标记配送4已完成用户确认收货或自动完成5已取消用户取消或超时未支付状态流转的方向要单一比如待支付只能走向已支付或已取消已支付只能走向制作中或用户申请退款简化为直接取消。这种约束靠枚举定义加业务层校验实现不要让订单状态出现“自由跳转”的可能。1.2 Spring Boot版本选型与依赖组合的取舍技术选型上我的建议一贯是Spring Boot 2.7.x Java 8 MyBatis-Plus MySQL 8.0 Redis Vue 3 Element Plus。这个组合不是最新的但绝对是最稳的。为什么不要盲目追Spring Boot 3.x网上关于“Spring Boot版本太高”的求助一搜一大把根因是Spring Boot 3.0基于Jakarta EE 9把javax包迁移到了jakarta包这意味着很多老教程里的import javax.servlet.*全部失效同时Spring Boot 3要求Java 17起步。如果你本机装的是Java 8或者参考的论文、博客是2022年之前写的硬上3.x会在一开始就被环境问题劝退。2.7.x是Spring Boot 2.x系列的最终版本兼容性和资料丰富度都是最好的。持久层用MyBatis-Plus而不是原生MyBatis或Spring Data JPA理由有三个第一单表CRUD不需要写SQLBaseMapper自带方法直接搞定第二LambdaQueryWrapper写条件查询非常直观比拼接XML字符串舒服太多第三分页插件PaginationInnerInterceptor是现成的毕业设计里“分页查询菜品列表”这种需求一行配置就能实现。如果你未来打算进企业用MyBatis生态这个选择也不偏离主流。Redis在这个项目里的角色我定位为“可选但推荐”。它的加分项很明确菜品分类缓存、购物车临时存储、验证码存储。但要注意毕设答辩时老师会追问“为什么用Redis”你必须答出Redis相比内存变量和数据库表的优势比如支持过期时间、持久化、数据结构丰富。如果你对Redis操作不熟也完全可以先用数据库表实现购物车把Redis当作加分项而不是必选项。2. 数据库模型拆解订单为什么要拆分主表和明细表2.1 核心表的职责划分与关键字段设计数据库设计是所有后续开发的地基。外卖点餐系统至少需要六张核心表用户表、分类表、菜品表、购物车表、订单表、订单明细表。我要强调的重点是订单表的拆分逻辑。订单主表orders保存一次下单的整体信息一个订单对应一条记录。关键字段包括订单编号order_no需要唯一且可读性强、用户ID、收货地址快照省市区详细地址联系人电话这是从地址表冗余过来的、订单总金额、状态用的是上面的int状态、支付时间、创建时间、更新时间。订单明细表order_detail保存订单里的每一道菜一个订单对应多条记录。关键字段包括订单ID、菜品ID、菜品名称、菜品图片、单价、数量、小计金额。请特别注意为什么明细里要冗余菜品名称和单价而不是下单时只存菜品ID去关联菜品表因为菜品表的数据是可变的——商家今天把鱼香肉丝从18块调到20块如果你去关联查询历史订单显示的价格和名称就全变了。订单一旦生成快照不可变这才是正确的商业逻辑。建表时还要注意两个细节。第一金额字段用decimal(10,2)而不是float或double二进制浮点数在涉及金额比较和累加时会产生精度误差这是做支付类系统的基本常识。第二所有表都加上create_time和update_time用MySQL的CURRENT_TIMESTAMP自动填充省去业务代码里手动set时间的繁琐操作。2.2 索引设计与购物车的两种实现路线购物车表是最容易被低估的表。有的同学直接用Redis的Hash结构存储key是用户IDfield是菜品IDvalue是数量这么做开发效率高但Redis一旦重启数据就没了而且答辩时你得解释清楚购物车为什么可以容忍丢失。我的建议是购物车用数据库表实现核心字段只有用户ID、菜品ID、数量、加购时间加上一个UNIQUE索引(用户ID, 菜品ID)来保证同一个用户对同一道菜只有一条记录。这样加购操作变成“存在则更新数量不存在则插入”用一个saveOrUpdateByWrapper就能完成。索引设计上最容易忽略的是订单表的查询性能。用户查看“我的订单”列表SQL条件是WHERE user_id ? ORDER BY create_time DESC所以组合索引(user_id, create_time)必须有。商家后台查询“待处理订单”条件是WHERE status ?给status加普通索引就是够用的。菜品表按分类查询给category_id加索引。如果你在答辩时能主动说出“我是基于查询场景设计的索引”这已经超过相当一部分同龄人了。3. 登录鉴权落地JWT 拦截器的完整实现方案3.1 JWT工具类与密码加密的正确姿势外卖系统至少有两种角色普通用户和商家管理员。我用一张user表加上role字段0-用户1-商家2-管理员来区分简单直观。登录接口验证用户名密码成功后生成一个JWT令牌返回前端前端后续请求在请求头里带上Authorization: Bearer 后端通过拦截器解析令牌识别用户身份。JWT工具类说难不难但有几个细节容易踩坑。第一密钥要足够长至少32个字符不要用abc123这种。第二token要设置过期时间我建议普通用户token设2小时并预留一个刷新机制毕业设计可以不做刷新但你要知道企业项目里JWT过期后不会让用户重新登录。第三JWT的载荷里只需要放userId和role这两个核心信息不要塞用户密码之类的敏感数据。密码存储必须用BCrypt加密。Spring Security里自带BCryptPasswordEncoder如果你不想引入整个Spring Security它的过滤器链配置会让新手头疼可以单独引入spring-security-crypto依赖只用到里面的加密工具类。注册时对明文密码加密存储登录时matches方法校验永远不要明文存密码也永远不要用MD5——MD5已经被彩虹表打穿多少年了。这一条放到答辩里也算安全亮点。3.2 拦截器注册与放行白名单的经典坑拦截器是鉴权的核心入口但新手最容易在“放行”这里翻车。我见过太多同学配好了拦截器结果前端页面死活打不开F12一看全是401原因就是把静态资源或登录接口拦了。正确做法是先列清放行白名单登录接口、注册接口、菜品浏览接口、菜品详情、图片资源路径/upload/**、以及Vue打包后的静态资源。使用Spring Boot 2.7.x时如果前端资源放在classpath:/static/下拦截器默认会拦截所有/**请求所以excludePathPatterns里必须把这些路径全部加进去。注册拦截器还要注意WebMvcConfigurer的写法不要继承WebMvcConfigurationSupport否则静态资源映射会失效——这个坑我在后面会专门再讲。ThreadLocal保存当前登录用户是一个值得展开的设计。拦截器解析token拿到用户ID后通过UserHolder.set(userId)存到ThreadLocal业务层需要当前用户ID时直接UserHolder.get()接口方法签名里就不用处处传userId参数了。请求结束后记得在afterCompletion里UserHolder.remove()避免线程池复用导致的用户数据串号问题。4. 业务链路代码落地从加购到超时取消的完整实现4.1 下单接口的事务边界下单是整个系统最核心的写操作至少涉及四件事校验菜品是否存在且上架、计算订单金额、保存订单主表和明细表、清空购物车。这四步必须处于同一个事务中否则会出现订单保存成功但购物车没清空的数据不一致问题。我的做法是在Service方法上标注Transactional(rollbackFor Exception.class)。这里必须写rollbackFor因为Spring默认只在遇到RuntimeException时才回滚而某些业务的自定义异常继承的是Exception不加rollbackFor的话事务不会回滚会造成脏数据。另外一个细节是事务只对public方法生效而且不能同类内部调用——同类方法之间调用会绕过代理导致注解失效。如果你把下单步骤拆到同一个类里的多个private方法事务是不生效的。这个点面试经常问写代码时也要刻意避开。订单金额计算一定要以数据库查出来的价格为准绝不能信任前端传过来的金额。前端传入的只是菜品ID和数量后端根据菜品ID查出当前单价乘法累加得到总金额。这样做既防止用户篡改请求参数又保证金额来源的唯一性。再用一个乐观锁或版本号控制菜品表并发修改毕设阶段可以按“菜品价格修改概率极低”这个假设简化但你心里要清楚这是一个可以深挖的优化点。4.2 模拟支付与基于定时任务的超时关单真正的支付对接需要商户号、证书、回调域名这些对毕设来说都不现实所以“模拟支付”是最务实的方案。我做的模拟支付很简单订单提交后跳转到支付确认页点击“确认支付”按钮后端直接把订单状态从待支付改成已支付再顺手把支付时间写入。在答辩时实话实说“这是模拟支付真实场景下会调用微信或支付宝的统一下单接口并通过回调通知更新订单状态”反而会让老师觉得你思路清晰。超时未支付自动取消对应的技术方案选择也值得说一说。网上很多帖子一上来就讲RabbitMQ延迟队列、死信队列理论没错但对毕设项目来说引入一套消息中间件会让部署复杂度成倍上升。我的建议是用Spring Boot自带的Scheduled定时任务每30秒扫描一次待支付且创建时间超过15分钟的订单批量更新为已取消状态。实现起来就是在配置类加EnableScheduling注解在方法上加Scheduled(fixedDelay 30000)用LambdaQueryWrapper查一圈再update。虽然定时任务有轮询延迟但对毕设场景完全够用。cancelOrder方法体核心逻辑如下Override public void cancelExpiredOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); LambdaUpdateWrapperOrder wrapper new LambdaUpdateWrapper(); wrapper.eq(Order::getStatus, OrderStatus.UNPAID.getCode()) .lt(Order::getCreateTime, deadline); Order update new Order(); update.setStatus(OrderStatus.CANCELED.getCode()); update.setCancelReason(超过15分钟未支付系统自动取消); orderMapper.update(update, wrapper); }这段代码要在Service层加Transactional并且注意updateTime字段如果数据库里设置了ON UPDATE CURRENT_TIMESTAMP就不用手动维护。4.3 购物车合并与订单明细持久化的实现顺序用户提交订单时购物车里的多条记录需要一次性转换为订单明细。典型的流程是查出该用户购物车列表→遍历列表校验菜品状态→累计总金额→构造订单主表插入→遍历购物车构造detailList批量插入→删除购物车记录。MyBatis-Plus的ServiceImpl自带saveBatch方法批量插入明细表性能良好。这里有一个操作顺序问题必须先插入订单主表拿到自增订单ID再插入明细表因为明细表的order_id依赖主表的id。有的同学先插明细再插主表导致外键关联不上报错后回来改就很麻烦。5. 前端资源集成与部署上线实操5.1 Vue打包如何正确地“塞进”Spring Boot前后端分离的开发模式下本地开发用Vite启动前端服务通过代理转发请求解决跨域。但部署上线时最简单的方式是把Vue构建产物放入Spring Boot的静态资源目录让后端同时提供页面和API最终打成一个jar包直接java -jar运行。这个方案省去了单独配置Nginx的步骤对毕设演示和答辩部署来说是最省心的。操作流程并不复杂前端执行npm run build后把dist目录下的所有文件复制到后端项目的src/main/resources/static/下。注意是复制内容不是把dist文件夹整个放进去否则路径会多一层/dist。Spring Boot默认把classpath:/static/作为静态资源根路径打包后访问http://ip:8080/就能看到页面。index.html里引用的是绝对路径/resources/xxx.js时需要配置一下WebMvcConfigurer的addResourceHandlers否则资源路径对不上。但这里有个必须处理的隐患每次重新打包前端静态资源里的index.html会被后端拦截器拦截吗如果你的拦截器白名单里没有/那访问首页可能返回401。所以白名单里务必放行/、/index.html、/assets/**以及所有静态资源后缀.js、.css、.png等。我在项目里用的是路径匹配方式把/放行排除掉API前缀/app/然后拦截器只拦截/app/**下的请求这样静态资源和业务接口永远不会冲突。5.2 宝塔面板 Docker Compose 部署环境配平服务器部署方面我推荐用宝塔面板搭配Docker Compose把MySQL、Redis和应用容器化编排。直接贴一个实际可用的compose文件要点MySQL容器映射3306端口、设置root密码和数据库名、挂载数据目录Redis容器映射6379Spring Boot应用构建成镜像通过depends_on保证数据库先启动。Spring Boot的Dockerfile如果使用多阶段构建体积会小很多。第一阶段用maven:3.8.4-jdk-8镜像执行mvn package第二阶段用openjdk:8-jre-alpine把jar复制进去。注意2.7.x的Java 8项目用openjdk:8没错Java 17项目才需要openjdk:17。构建出来的jar包里已经带了前端静态资源所以一个容器就同时支撑页面和API。应用配置文件里最大的坑是数据库地址。本地开发写localhost部署到Docker里就不能继续用localhost——这时要写MySQL容器名比如jdbc:mysql://mysql:3306/xxxDocker Compose内部网络会根据服务名解析。另外MySQL 8.0的驱动要加上时区参数serverTimezoneAsia/Shanghai否则查出来的时间会比北京时间少8小时。图片上传的存放路径也别放在/upload这种相对路径下最好映射到一个宿主机目录防止容器重建后图片丢失。6. 踩坑实录版本冲突、跨域放行和自动化配置的几个典型问题6.1 Spring Boot自动装配与“版本太高”的真面目很多新手报错时看着一大坨异常无从下手其实Spring Boot框架本身就是一个巨大的自动化配置引擎它通过spring.factories或AutoConfiguration.imports加载大量*AutoConfiguration类根据classpath下有没有对应的类来决定要不要创建Bean。理解了自动装配你才能明白“版本太高”为什么会引发连锁反应。最常见的场景你搜到一个教程里面用Spring Boot 2.3的写法引入某个依赖但你项目是Spring Boot 3.2于是配置类中被ConditionalOnClass标记的类找不到或签名变了于是自动配置静默失效你的Redis、MyBatis配置全不生效程序要么启动报错要么诡异空指针。解决办法除了主动降版本还有一个思路是学会看依赖树在IDEA里打开pom.xml右键Maven→Show Diagram或者执行mvn dependency:tree排查版本冲突的高度有效。不要一上来就百度“为什么我的Redis用不了”先把版本对齐了再说。6.2 WebMvcConfigurer与WebMvcConfigurationSupport的取舍接上面5.1提到的问题我再展开讲一下这个经典大坑。自定义拦截器或静态资源映射时需要用Configuration类实现WebMvcConfigurer接口而不是继承WebMvcConfigurationSupport。当你继承WebMvcConfigurationSupport时Spring Boot的自动配置WebMvcAutoConfiguration会因为ConditionalOnMissingBean判断“用户已经自定义了MVC配置”而失效这会导致默认的静态资源映射、消息转换器、异常处理器全部丢失表现就是页面样式全无、文件上传失败、RequestBody解析崩溃。报错信息五花八门根子就一个。如果你确实已经继承了WebMvcConfigurationSupport导致问题可以改成实现WebMvcConfigurer。大多数毕业设计需要的自定义行为——拦截器注册、跨域配置、静态资源配置——通过实现接口都能做不需要继承。我在项目里全部用实现接口的方式写一行多余的继承都不用。6.3 拦截器放行路径的匹配规则与前端联调体验拦截器配置里另一个问题是路径匹配模式的语义。Spring的AntPathMatcher支持?、和*“/user/*”能匹配/user/abc但匹配不了/user/abc/edit“/user/**”则匹配任意层级。配白名单时我建议按前后缀拆分前端页面统一在/api/前缀下拦截器拦截/api/**并放行所有非/api路径这样以后再扩展页面也不用反复修改白名单。开发阶段的跨域问题则建议通过Vite的server.proxy配置解决而不是在后端开CORS全局允许。用代理时前端的/api路径请求被转发到localhost:8080浏览器看到的是同源请求自然没有跨域报错。而后端不需要加CrossOrigin也不需要配置allowCredentials等于true的全局CORS——那些配置解决的是另一种场景混着用反而容易出问题。7. 答辩前的项目打磨与几个可以深挖的扩展方向系统跑通只是第一步答辩时的高分亮点往往来自你对设计细节的主动阐述。我会建议你从三个维度去打磨。第一把代码里的魔法值处理掉。比如订单状态type用接口常量或枚举统一管理并提供一个根据code获取描述的方法这样前端显示“制作中”而非“2”。好的代码习惯在答辩时很加分因为老师看代码的机会比你看PPT的机会多。第二主动指出你做的技术选择并说明理由。用表格准备几个“为什么”为什么用JWT而不用Session——无状态、易水平扩展为什么用MyBatis-Plus——开发效率高且分页方案成熟为什么用定时任务关单——在项目复杂度不高时避免引入消息中间件虽然存在延迟但可以承受。这些回答能让老师觉得你不是背代码而是真正有自己的判断。定时任务方案如果被追问可以补充说明生产级系统更推荐延迟队列你能说出这个对比就足够了。第三增加一两个看得见的功能亮点。给系统加一个销售统计面板是性价比很高的选择比如用简单的SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, COUNT(*), SUM(amount) FROM orders WHERE status 1 GROUP BY day后端返回柱状图数据格式前端用ECharts展示近7日订单趋势。功能不复杂但视觉效果好还能体现你“懂一点数据分析”的意识。再进一步可以用WebSocket或SSE实现用户下单后商家端实时弹出新订单提醒这在技术栈上也是加分项。我在实际带项目的过程中发现大多数做这类系统的同学并不是代码写不出来而是从一开始打地基时没有把表和状态机设计清楚导致后期反复返工。你如果正在开题阶段建议先花一个下午专心把数据库脚本和订单状态流转图画清楚再动工写代码后面至少能少走一半弯路。这个顺序比我写过的任何一段代码都重要。