每年毕业设计季,总有一批人会直接下载一个带编号的源码包,改个包名就交上去。作为帮学弟排过不少坑的人,我必须说:校园外卖点餐系统这类项目,如果只停留在“能跑就行”,答辩很容易被问住。但反过来看,它又确实是Spring Boot入门和毕设的稳妥选题——业务真实、表结构经典、能覆盖登录、权限、增删改查、关联查询、订单状态流转这些核心技能。这篇文章就基于我用Spring Boot从零搭建这个系统的经历,把需求拆解、技术选型、数据库设计、主链路实现和最容易翻车的细节一次讲清楚。
1. 为什么“校园外卖点餐系统”是毕设的稳妥之选
1.1 选题的真实理由:业务闭环完整,技术点刚好够用
选毕设题目最怕什么?最怕两类:一类是纯管理系统,只有CRUD,做完了自己都觉得没分量;另一类是一上来就搞微服务、分布式事务、消息队列,结果大半年连环境都没搭明白。校园外卖点餐系统的好处在于它处在一个“中间态”:用户点餐、商家接单、平台管理,三方角色齐全,但业务复杂度完全可控。
从数据流来看,一套完整的外卖系统天然包含用户表、店铺表、菜品表、购物车表、订单表、订单明细表、地址表、分类表,这已经覆盖了“一对多”“多对一”几乎所有常见的表关系。订单状态从“待支付”到“已支付”“商家接单”“配送中”“已完成”,又要求你理解状态机的基本思想。你会用到关联查询、条件分页、事务、枚举映射、全局异常处理,这些恰好是Spring Boot开发里最高频的能力。
所以这个题目不是“太简单”,而是“该有的都有,但都不算深”。对本科生来说,能讲清楚整个业务闭环,再有一两个亮点,比如并发扣库存、XSS过滤、订单超时取消,答辩就非常稳了。
1.2 需求范围控制:先砍掉不该做的功能
很多同学拿到题目后第一反应是“我要做App、小程序、商家端、骑手端、管理后台、优惠券、会员积分……”。我建议你做减法。当初我第一次做这个项目也差点失控,后来把需求收敛成三个端:
- 用户端:登录注册、浏览菜品、加入购物车、下单、支付模拟、订单查询、地址管理、个人中心。
- 商家端:菜品管理、接单/拒单、订单状态更新、简单统计。
- 管理后台:用户管理、店铺审核、分类管理、订单查询、基础数据看板。
至于骑手端和实时定位,完全可以放到论文的“不足与展望”里。毕设考察的是你能否独立实现一个完整系统,不是能否复刻美团外卖。功能每多一个,前后端联调和数据库设计的成本会翻倍,没有足够的调试时间,最后交上去的反而是一个处处是Bug的半成品。
1.3 源码能跑起来只是起点,重要的是能讲清楚
标题里带编号的毕设源码,网上确实很多,但坦白讲质量参差不齐。有的代码没有注释,有的数据库脚本和实体类对不上,有的配置文件里甚至残留着别人的数据库密码。你如果只是把源码跑起来,答辩时老师随机问一个“订单状态是怎么流转的”就可能卡壳。
更稳妥的思路是:把源码当作参考,自己动手把核心表建一遍,把主流程写一遍,哪怕你是在别人代码基础上重构,也至少每行都能讲明白。源码编号68913本身不重要,重要的是这个编号对应的项目结构能不能成为你自己知识体系的一部分。带着“我要理解它”的心态去做,而不是“我要交差”的心态。
2. Spring Boot 项目骨架搭建:版本、依赖和分层的取舍
2.1 Spring Boot 版本到底怎么选
热词里反复出现“spring boot 2.7.18”“springboot版本太高”“idea不能创建springboot项目不能使用jdk1.8”,这些我都遇到过。我的实际建议是:如果复习资料、学校教学环境、你本地的JDK还是1.8,那请你老老实实选Spring Boot 2.7.18。这个版本是目前兼容JDK 8和JDK 11的最后一个稳定大版本,网上教程最多,遇到问题一搜就有答案。
如果非要追新用Spring Boot 3.x,那JDK最低也得17,同时你用的MyBatis-Plus、SpringDoc等依赖都要换成新坐标,版本兼容上容易出幺蛾子。毕业设计不是公司生产环境,不需要追求技术新,稳定跑通比什么都重要。
一个很实际的经验是:创建项目的时候不要选择“从 start.spring.io 拉最新的SNAPSHOT版本”,直接手动在pom.xml里锁定:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent>这能避免很多因为Spring Boot版本集体变迁带来的连锁问题。
2.2 核心依赖清单:别什么都往pom里塞
校园外卖点餐系统最常用的依赖其实不多,我的pom.xml里一般只保留这些:
| 依赖组 | 作用 | 说明 |
|---|---|---|
| lombok | 简化实体类代码 | 基础必加,但要注意团队规范 |
| spring-boot-starter-web | Web开发基础 | 内置Tomcat |
| mybatis-plus-boot-starter | 数据库ORM和分页 | 比JPA更容易控制SQL |
| mysql-connector-java | MySQL驱动 | 版本要和MySQL匹配 |
| spring-boot-starter-data-redis | 缓存/购物车/分布式锁 | 不强制,但能加分 |
| jjwt | JWT登录令牌 | 前后端分离必备 |
| spring-boot-starter-validation | 参数校验 | 必加,减少冗余判断 |
| knife4j 或 springdoc | 接口文档 | 答辩演示利器 |
| hutool | 工具类 | 非必选,但很方便 |
不要为了凑技术栈去加RocketMQ、Elasticsearch,除非你真的能说清楚它们在这个系统里解决了什么问题。当初我见过一个同学给外卖系统上了RabbitMQ,问他就说“用来处理订单消息”,但为什么不用数据库状态轮询,他完全答不上来——这种反而会成为答辩减分项。
2.3 项目分层:包结构直接决定你能不能睡好觉
很多毕设源码的包结构非常混乱,service里写SQL,controller里写业务逻辑,一旦报错根本定位不了。这里我给出一套适合毕设的分层规范:
com.campus.order ├── common // 统一返回结果、异常处理、常量 ├── config // 配置类(分页插件、WebMvc、Cors) ├── controller // 接口层 ├── service // 业务层,接口+实现 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体 ├── dto // 接收前端的参数对象 ├── vo // 返回给前端的视图对象 └── utils // JWT、日期等工具类这样做的好处是,答辩时老师问你“订单金额在哪里计算的”,你能直接说“在service层,先查购物车,再计算总价,折扣,最后生成订单”,而不是在controller里翻了半天。
我用MyBatis-Plus比较多,因为它的BaseMapper自带单表CRUD,能大幅减少重复代码,多表查询再手写XML。注意MyBatis-Plus版本不要和Spring Boot 3混用,2.7版本的Spring Boot配mybatis-plus-boot-starter 3.5.x用起来很舒服。
2.4 从application.yml开始,把环境一次配顺
资源目录下的application.yml,我习惯拆成三份:application.yml(通用)、application-dev.yml(开发环境)、application-prod.yml(生产环境演示)。毕设虽然不用很复杂,但如果你把数据库密码写在通用配置里,传到Git上被老师检查到是很扣分的。
关键的配置项主要有这些:
spring: datasource: url: jdbc:mysql://localhost:3306/campus_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath*:mapper/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0注意serverTimezone=Asia/Shanghai,这个不加,本地跑没感觉,部署到服务器后查出来时间差8小时,容易在演示时闹笑话。还有map-underscore-to-camel-case要开,否则数据库字段create_time映射到createTime会很痛苦。
3. 数据库设计:核心表关系梳理与实操细节
3.1 表不是越多越好,先看主链路需要哪几张
很多参考源码动辄二十几张表,看的头皮发麻。其实你从头梳理一条“用户下单”的主链路,就能推导出核心表:
- 用户要登录:需要
user表。 - 用户要选店铺和菜品:需要
shop表、category表、dish表。 - 用户把菜加入购物车:需要
cart表,也可以直接用Redis缓存,但落库更直观。 - 用户下单:需要
orders表保存订单主表,需要order_detail表保存菜品快照。 - 用户需要收货地址:需要
address表。 - 商家需要管理自己的菜品:
shop表里加user_id字段关联商家账号。 - 管理员需要看所有订单:已经有
orders表足够了。
我是这样设计字段风格的:主键用id bigint自增,业务字段用下划线命名,时间字段统一为create_time、update_time逻辑删除用deleted tinyint。以下是一张简化后的订单表结构,供参考:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(32) | 订单编号,业务上唯一 |
| user_id | bigint | 下单用户 |
| shop_id | bigint | 店铺 |
| total_amount | decimal(10,2) | 总金额 |
| status | tinyint | 订单状态:0待支付,1已支付,2已接单,3配送中,4已完成,5已取消 |
| address_id | bigint | 收货地址 |
| remark | varchar(255) | 备注 |
| pay_time | datetime | 支付时间 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
| deleted | tinyint | 逻辑删除 |
这里你要注意order_no不能省。订单表用自增主键没问题,但对外展示和异步回调时需要一个唯一的订单号,一般用“时间戳+随机数”生成,或者直接用雪花算法。
3.2 订单状态字段:不要用字符串到处飘
订单状态是外卖系统的灵魂。很多新手的做法是status字段直接写字符串“已支付”“待支付”,然后前端展示时显示字符串。这样问题很大:一是数据库存储空间浪费,二是状态判断容易写错,三是没法做统计。
更合理的做法是status用tinyint,同时在代码里定义一个枚举类:
public enum OrderStatus { UNPAID(0, "待支付"), PAID(1, "已支付"), ACCEPTED(2, "已接单"), DELIVERING(3, "配送中"), COMPLETED(4, "已完成"), CANCELLED(5, "已取消"); private final int value; private final String desc; OrderStatus(int value, String desc) { this.value = value; this.desc = desc; } }这样改的好处是,你在service层可以用枚举来做状态流转判断,比如只有PAID状态才能ACCEPTED,逻辑一目了然。答辩时老师看到你用了枚举而不是魔法数,印象分会高不少。
3.3 逻辑删除与时间字段,这些小细节别偷懒
MyBatis-Plus支持全局逻辑删除,我上面给了配置。这里有个坑:如果你用了逻辑删除,那么所有查询MyBatis-Plus会自动追加deleted=0的条件,但你自己手写的XML里也要记得加条件,否则会出现“已删除的数据还能查出来”的Bug。我就在踩过一次后把所有手写SQL都检查了一遍。
时间字段建议直接用LocalDateTime,配合MyBatis-Plus的自动填充:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }实体类上对应字段加@TableField(fill = FieldFill.INSERT)和@TableField(fill = FieldFill.INSERT_UPDATE),就不用每次手动set时间了。
3.4 从ER设计到SQL脚本,准备一份能直接导入的数据库
毕设代码包里一定要附带一份campus_order.sql,并且要保证这份SQL能直接导入MySQL后项目跑起来。我见过很多源码里SQL脚本是残缺的,或者有外键约束顺序不对导致导入失败。最稳妥的做法是:你本地把整个系统测完,然后用mysqldump导出一份最新的完整脚本,手动再导入测试一遍。
SQL脚本里建议包含:
- 建库语句
- 建表语句
- 基础数据:如管理员账号、测试店铺、几个菜品
有这仨,老师拿到项目后从零初始化很快,你演示时也不用手忙脚乱地造数据。
4. 主链路功能实现:从登录到下单再到订单完成的完整逻辑
4.1 登录认证:JWT的前后端分离实践
校园外卖点餐系统如果是前后端分离的,登录状态不能依赖Session。我用的是JWT,流程很简单:
- 用户输入账号密码,后端校验通过后生成token。
- token里只放
userId和角色信息,不放大段敏感数据。 - 前端把token存在localStorage,请求时放在
Authorization头里。 - 后端写一个拦截器或过滤器,解析token并放入
ThreadLocal。
注意两个坑:第一,JWT的密钥要硬编码在配置里,不要写死业务代码中;第二,JWT是无状态的,一旦签发很难主动失效,所以你需要在用户表加一个status字段,被禁用时拦截器里检查一下。如果不想太复杂,最简单的处理是:登录后把token存一份到Redis,设置过期时间,每次请求时校验Redis中是否存在,这样管理后台就能强制退出用户了。
核心拦截器代码大概长这样:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } // 校验token Claims claims = JwtUtil.parseToken(token); if (claims == null) { response.setStatus(401); return false; } UserContext.setUserId(claims.get("userId", Long.class)); return true; } @Override public void afterCompletion(...) { UserContext.clear(); } }4.2 点餐流程:购物车、菜品库存与价格校验
用户端点餐的常规路径是:展示店铺分类和菜品列表,用户把菜品加入购物车。购物车我建议先存Redis,因为它的特点是高频读写、实时性要求高,用Redis的Hash结构很舒服:
key: cart:userId:shopId field: dishId value: quantity这样用户切换店铺时也方便做“清空购物车”或者“提示是否清空”。但是,如果你的毕设想减少复杂度,购物车直接建一张cart表存数据库也完全没问题,查询和修改都更直观。对于毕设来说,我更倾向于数据库表方案,因为论文里好画E-R图,答辩也好解释“购物车数据持久化”。
下单时最关键的是价格和库存的校验。很多源码只校验了“菜品是否为空”,却没有校验价格是否被前端篡改。正确流程应该是:
- 根据购物车里的菜品ID,重新从数据库查出单价。
- 单价乘以数量,累加成总金额。
- 校验菜品是否在售、状态是否正常。
- 生成订单主表和订单明细表。
- 扣减库存。
这里要特别提醒:库存扣减一定不能放在前端传参里告诉后端“扣多少”,而是后端根据购物车数量计算。这里我用一个简单的事务注解:
@Transactional(rollbackFor = Exception.class) public Order createOrder(Long userId, Long shopId, Long addressId, String remark) { // 1. 查询购物车 // 2. 计算总价 // 3. 保存订单主表 // 4. 保存订单明细 // 5. 扣减库存(注意并发问题) // 6. 清空购物车 }4.3 支付模块:模拟支付和真实支付的取舍
校园外卖系统接入微信支付/支付宝,对毕设来说成本和流程都很重。我建议做“模拟支付”:在订单表增加pay_status字段,点击“支付”后走一个模拟回调接口,直接把订单状态从“待支付”改成“已支付”。这在数据库层面还是保留pay_time、transaction_id字段,方便以后扩展。
如果你的论文想写“接入了微信支付”,那至少要有一个沙箱环境,而且要有完整的回调验签逻辑。这里我不建议自研,因为支付回调涉及安全性,做不好反而被老师追问出漏洞。用模拟支付,你在论文里就写“为了演示方便,本系统采用模拟支付方式,真实环境中可替换为第三方支付接口”,这样既诚实又合理。
4.4 订单状态机与超时取消
订单不是“支付成功”就结束了,商家端还要接单、配送、完成。我建议你把状态流转抽成一个独立方法,保证非法状态切换能被拦截:
public boolean canTransfer(int currentStatus, int targetStatus) { switch (currentStatus) { case 0: return targetStatus == 1 || targetStatus == 5; // 待支付 -> 已支付/取消 case 1: return targetStatus == 2 || targetStatus == 5; // 已支付 -> 已接单/取消 case 2: return targetStatus == 3 || targetStatus == 5; // 已接单 -> 配送中/取消 case 3: return targetStatus == 4; // 配送中 -> 已完成 default: return false; } }“用户下单后不支付,订单一直占着库存”也是外卖系统必须处理的点。最简单的方案是用Spring的@Scheduled定时扫描:每30秒查一次“待支付且创建时间超过30分钟”的订单,将其置为取消,并回补库存。这里要注意定时任务只适合单机应用,多实例部署时会有重复执行问题,答辩时你可以主动提到“生产环境可以用RabbitMQ延迟消息”,但实际代码里做一个定时任务就足够了。
5. 实战中的隐藏坑点:XSS过滤、分页查询与并发扣库存
5.1 全局过滤器处理XSS攻击,这个细节很加分
热词里提到“springboot项目全局过滤器处理上传pdf文件时xss攻击”,虽然具体场景是上传PDF,但XSS防御在表单提交场景更常见,尤其是外卖系统里有“店铺介绍”“菜品描述”“订单备注”这些文本字段,很容易被插入<script>标签。
最简单的做法是写一个过滤器,对请求体中的字符串做HTML转义:
public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { chain.doFilter(new XssHttpServletRequestWrapper((HttpServletRequest) request), response); } } // XssHttpServletRequestWrapper 中重写 getParameter/getHeader/getInputStream // 将 <script> 等字符转义为 <script>同时要注意,后端不能只在控制器里处理,因为很多参数是JSON格式,你还需要重写getInputStream,把body里的JSON字符串做过滤。我通常在CommonConfig里注册这个过滤器,并设置拦截所有路径。
5.2 MyBatis-Plus分页插件的配置误区
热词里有“mybatis的分页插件的用法 springboot”,这个几乎每个项目都会用。MyBatis-Plus分页本身不复杂,但很多人漏了分页插件配置,结果Page对象查出来不带总数:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个Bean不配置,分页查询的total一直是0,列表页没法做分页组件。还有一种常见问题:联表查询时分页总额不对。用MyBatis-Plus的自定义SQL时,建议写成select 主表需要的字段,不要select *,并且分页插件会自动生成count语句,如果count语句复杂可能报错,可以在XML里单独写一个countSql。
实际使用分页时,我一般是:
Page<OrderVO> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Order::getUserId, userId).orderByDesc(Order::getCreateTime); Page<OrderVO> result = orderMapper.selectOrderPage(page, wrapper);这样controller就能拿到records、total、current、size,前端直接对接即可。
5.3 并发下单时的库存超卖问题
“秒杀”场景在校园外卖里虽然不常见,但高峰期多个用户同时点同一道菜时,库存扣减会产生超卖。最常见也最容易实现的方案是用乐观锁。在dish表增加version字段,扣库存时执行:
UPDATE dish SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{dishId} AND version = #{oldVersion} AND stock >= #{quantity}然后检查更新行数,如果为0,说明库存不足或版本冲突,就抛出业务异常。基于Spring的@Transactional,这个方案在毕设项目里已经足够。如果想更高级,可以用Redis的decr配合lua脚本扣库存,但答辩时你不一定能解释清楚,所以还是优先选数据库乐观锁。
5.4 时间时区与前端显示不一致
这个问题在很多毕设项目里都存在。后端返回的LocalDateTime如果带T格式,前端直接用字符串展示会很丑。我的做法是,在application.yml配置统一格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai同时在VO里用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")作为双重保障。这种细节老师一眼就能看出来你是有工程经验的。
6. 毕业设计答辩准备与项目演示的加分细节
6.1 论文章节怎么跟代码对应
论文不要写成“目录版用户手册”,建议按“需求分析 -> 系统设计 -> 数据库设计 -> 核心功能实现 -> 系统测试”来组织。代码实现那一章,不要贴大段代码,而是贴“关键方法和流程图”并用文字说明逻辑。比如讲订单模块,贴createOrder方法和状态机流转描述,比贴完整类更有说服力。
我在带学弟时常用一个笨办法:论文里每一节提到“系统实现了XXX”,就在代码里找一两个核心方法作为证据。评审老师看论文时,会突击翻代码,如果你论文里的命名和代码里对不上,印象分一下子掉很多。
6.2 演示前准备一套能自洽的数据
演示翻车多数出在数据上:登录密码不对、订单列表是空的、图片加载不出来。我建议本地准备一份“演示数据脚本”:
- 一个管理员账号:admin / admin123
- 一个用户账号:student / student123
- 两个测试店铺,每个店铺至少有5个菜品
- 每个店铺至少有一个已完成的订单和一个待支付订单
演示顺序也最好定死:先演示登录,然后是用户端浏览菜品加购物车,接着提交订单模拟支付,再切到商家端接单,最后管理后台看数据。这一步一步必然是连贯的,如果中途靠手动改数据库来造假,一旦被老师看出来就是事故。
6.3 常见答辩问题和回应思路
外卖系统答辩最容易被追问的问题,我提前整理一下:
| 老师可能会问 | 建议回答思路 |
|---|---|
| 订单状态为什么会用枚举? | 避免魔法数,集中管理状态流转,校验非法状态切换 |
| 库存超卖怎么解决? | 乐观锁/版本号,同时检查更新行数 |
| JWT和Session有什么区别? | JWT无状态可扩展,适合前后端分离,但注意续期和注销问题 |
| Redis在你的系统里用在哪? | 购物车/验证码/缓存,讲清楚缓存和数据库的一致性 |
| 如果用户下单后一直不支付怎么办? | 定时任务扫描超时订单,取消并回补库存 |
| 前端传的价格你能信吗? | 不信,后端重新查库计算价格 |
回答时不用背“标准答案”,抓住“我实际是怎么做的”和“这个方案有什么局限”这两点,老师会很认可你的工程思维。
6.4 这个项目后续还能怎么扩展
答辩最后如果老师问“你后续打算怎么改进”,你可以说这几个方向:接入真实第三方支付、引入延迟消息队列实现订单超时取消、增加首页推荐算法、多角色骑手配送调度、用Docker部署。但记住,说出来的每个扩展点,你至少要知道大概实现原理,不要吹得太大。
我的个人建议是:哪怕论文提交了,也可以继续把项目部署到云服务器,用Nginx+Spring Boot jar包跑起来,手机端能访问。这样面试时你直接给对方演示在线地址,比在本地IDE里跑效果好得多。部署过程本身也会让你更理解Linux、防火墙、数据库远程连接这些真实开发中的东西。
最后再分享一个小技巧:整个系统完成后,记得给项目写一个README.md,把技术栈、运行环境、启动步骤、测试账号、常见问题都写清楚。这不仅是给老师看的,也是你几个月后回来复习时的救命文档。我每带一个学弟做这类毕设,都会先让他把README写个初稿,再对照着把它跑通。你会发现,写清楚一套启动文档,比应付答辩更能检验你是不是真的理解了这个项目。