简介:基于Spring Boot与Vue的校园外卖服务系统设计与实现资料包,面向Java学习者、毕业设计及课程作业场景。系统覆盖管理员端外卖列表、订单状态维护、公告信息发布与公告类型管理等核心功能,其中外卖列表可查看订单号、顾客信息与订单状态,公告管理支持分类发布通知与促销内容;整体采用MySQL数据库与前后端分离架构,配套SQL初始化脚本、完整Java/Vue源码以及安装构建工具,适合需要快速搭建类似系统、完成课程设计或撰写论文的读者。资源共791个文件,包含java后端源码、vue组件、js交互脚本、css样式表,以及xml配置、json数据、gif演示动图、mp4操作视频和少量文档,压缩包约26.52MB,目录结构清晰,便于按模块查阅;内含一键安装、运行、构建脚本,可快速启动项目。已有101人学习下载。通过完整源码及功能模块说明,可掌握Spring Boot整合Vue的项目分层结构、管理员端功能开发思路,同时获得数据库表设计与毕业论文写作的参考素材,对完成毕业设计具有实际帮助。
1. 基于SpringBoot校园外卖服务系统的选题逻辑与落地边界
校园外卖这个毕设题每年都有人选,但多数实现停在了“登录加 CRUD”。真正拉开差距的是订单状态怎么流转、并发下数据怎么保持一致——这恰好是 Spring Boot 生态里最典型的工程场景。系统按四类角色划分:学生下单评价、商户接单出餐、骑手更新配送进度、平台管理员审核资质,天然能把权限模型讲清楚。
选 Spring Boot 的理由直白:内置 Tomcat 让部署只靠 java -jar,MyBatis Plus 和 Spring Data Redis 把集成成本压到最低,源码可读性也适合论文画架构图。Spring Boot 3.x 要求 Java 17,毕设直接以 JDK 17 起步即可。
适合 Java 基础中等、想把毕设做到能演示、能压测、能讲清设计取舍的人群。后面的路径依次是:分层与数据模型、认证与状态机、并发控制、压测排错。
2. SpringBoot校园外卖系统的分层架构与订单数据模型
2.1 Controller-Service-Mapper 分层与请求链路边界
校园外卖后端的分层不复杂,Controller-Service-Mapper 三件套足够覆盖全部模块。真正的坑在于层与层的职责会不自觉越界。见过不少毕设代码里 Controller 里写菜品金额计算、Service 里直接用 HttpSession 取登录用户,短期能跑通,答辩时老师一句“为什么要分这么多层”就答不上来。
我一般会把边界定死:Controller 只做参数接收、参数校验和统一结果包装,不出现任何业务判断;Service 只做业务编排、事务控制和状态校验,不出现 HttpServletRequest、HttpServletResponse 这类 Web 层对象;Mapper 只做数据访问,筛选条件全部用 MyBatis Plus 的 LambdaQueryWrapper 表达。
这套分层的职责边界整理成表,写进论文设计章节可以直接用:
| 层级 | 职责 | 禁止事项 |
|---|---|---|
| Controller | 参数接收、参数校验、结果包装 | 禁止写金额计算、禁止直接操作 Mapper |
| Service | 业务编排、事务控制、状态校验 | 禁止出现 Web 层对象 |
| Mapper | SQL 执行、单表 CRUD、复杂查询 | 禁止包含业务判断 |
请求从入口到落库的链路是:前端 JSON 到达 Controller,转成 DTO 后调用 Service;Service 内部开启事务,完成数据校验后逐条写入数据库;响应统一返回 Result ,格式固定为 code、message、data 三段。答辩时能徒手画出这条链路,比背十个 Spring 面试题都有效。
2.2 订单主表与明细表的拆分原则、建表 SQL 与自动填充
订单表是这套系统的核心。很多毕设把订单和菜品放同一张表,一个订单三条菜就存三条记录,查详情要按 order_id 分组。正确做法是拆成订单主表和订单明细表:主表保存一次订单的汇总信息,明细表保存每条菜品记录的快照。拆分理由有三条:状态变更只更新主表,明细表只读保持稳定;统计商户营业额聚合主表即可;退款场景下明细表提供可操作的数据粒度。
订单号建议不用自增 ID 暴露给前端,改用时间戳加随机数生成业务订单号,大致格式是 yyyyMMddHHmmss 加 6 位随机数。原因很实际:自增 ID 会把平台当日订单量泄露出去,也方便别人遍历接口。这个细节写进论文里,能体现出对业务安全的考虑。
两张表的核心建表 SQL 放一起看:
CREATE TABLE `orders` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL COMMENT '用户ID', `merchant_id` BIGINT NOT NULL COMMENT '商户ID', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付1已支付2制作中3配送中4已完成5已取消', `address_detail` VARCHAR(255) NOT NULL COMMENT '配送地址', `remark` VARCHAR(255) DEFAULT NULL COMMENT '备注', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', KEY `idx_user_id` (`user_id`), KEY `idx_merchant_id` (`merchant_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; CREATE TABLE `order_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', `order_id` BIGINT NOT NULL COMMENT '订单主表ID', `dish_id` BIGINT NOT NULL COMMENT '菜品ID', `dish_name` VARCHAR(100) NOT NULL COMMENT '菜品名称快照', `price` DECIMAL(10,2) NOT NULL COMMENT '单价快照', `quantity` INT NOT NULL COMMENT '数量', `subtotal` DECIMAL(10,2) NOT NULL COMMENT '小计', KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';这段 SQL 的逻辑说明:orders 表的 status 用 TINYINT 存数字状态,0 到 5 对应订单生命周期里的六个状态,枚举含义写在注释里,代码里可以用常量类对应;order_item 冗余了 dish_name 和 price 两个字段,这是刻意做快照——菜品改价或下架后,历史订单仍能按当时的价格和名称展示。这个设计点叫“历史数据不可变”,论文里可以单独展开。
注意两张表之间只用索引不用物理外键。高并发写入场景下物理外键每次插入都要做约束检查,拖慢事务;数据一致性交给 Service 层事务控制,由程序保证而不是数据库兜底。
创建时间和更新时间推荐交给 MyBatis Plus 的自动填充处理,不用在每个 Service 方法里手动 set。实现方式是注册一个 MetaObjectHandler:
@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()); } }这段代码的作用是在实体执行 insert 和 update 时自动写入时间字段。参数说明:第一个参数是实体元数据对象,第二个是字段名,第三个是类型,第四个是填充值。记得在实体字段上加 @TableField(fill = FieldFill.INSERT) 和 @TableField(fill = FieldFill.INSERT_UPDATE),否则填充逻辑不会触发。另外,如果建表 SQL 里 update_time 已经带了 ON UPDATE CURRENT_TIMESTAMP,代码层就不要再用自动填充更新它,两套机制并存会出现时间字段来源不一致的问题,二选一即可。
2.3 四类角色的权限设计与 Spring Security 取舍
校园外卖系统有没有必要引入完整的 Spring Security?我的判断是可以不用。毕设场景下引入 Spring Security 意味着要处理 Filter 链、UserDetailsService、密码加密器等一系列配置,整体调试成本不低。更推荐的方案是自定义拦截器加 JWT,下一章展开实现。
如果论文方向偏工程、需要体现安全设计完整性,用 Spring Security 的 @PreAuthorize 方法级授权也能讲通。取舍的边界在于:自定义拦截器适合角色维度少、细粒度控制需求弱的场景;Spring Security 适合要做按钮级权限、接口权限动态配置的场景。校园外卖的四类角色数量固定且权限边界清晰,自定义方案能省一半时间。角色与权限的关系建议落在数据库菜单表上而不是写死在代码里,管理后台增减权限时不用重新发版。
3. SpringBoot 订单模块的 JWT 登录认证与状态机实现
3.1 JWT Token 生成、拦截器校验与用户上下文传递
登录态方案选择 JWT 的原因很直接:系统同时服务 App 端和 Web 管理后台,服务端不依赖 Cookie,token 放在请求头里就能跨端生效。JWT 分 Header、Payload、Signature 三段,Header 声明算法,Payload 放用户信息,Signature 用密钥做防篡改校验。
Token 生成的工具类可以直接用 jjwt 库:
public class JwtUtil { private static final SecretKey KEY = Keys.hmacShaKeyFor( "campus-order-secret-2026!".getBytes(StandardCharsets.UTF_8)); public static String createToken(Long userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000L)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); } }代码逻辑说明:createToken 把用户 ID 放进 subject,role 声明里存放角色名,过期时间设为 2 小时;parseToken 负责解析和签名校验,签名不一致会直接抛异常,调用方不需要自己判断。密钥这里写常量只是为了演示,真实项目要放到 application.yml 或环境变量里注入,别让密钥进代码库。
校验链路需要一个拦截器,在请求进入 Controller 之前解析 token:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或登录态已过期"); } Claims claims = JwtUtil.parseToken(token.substring(7)); UserContext.set(claims.get("userId", Long.class), claims.get("role", String.class)); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }拦截器逻辑说明:preHandle 里先放行非 HandlerMethod 的请求,比如静态资源;然后从 Authorization 头取 Bearer token,解析成功后把用户信息写入 UserContext。UserContext 背后是一个 ThreadLocal,作用域是当前请求线程。关键在 afterCompletion 必须调用 clear,否则 Tomcat 线程池复用线程时,上一次请求的用户信息会泄漏到下一次请求。
注册拦截器时注意放行规则:登录、注册、菜品浏览接口放行;下单、查询订单、商户管理、骑手接单全部拦截。放行路径按接口前缀配置,比如 /api/auth/** 放行、/api/order/** 拦截。
3.2 订单状态机的合法流转路径与统一校验
订单状态是答辩必问的模块。设计目标只有一个:状态迁移路径要收窄,不能出现平台管理员把订单从已取消改成配送中的操作。六个状态整理成流转表:
| 当前状态 | 可流转到 | 触发动作 |
|---|---|---|
| 0 待支付 | 1 已支付、5 已取消 | 用户支付 / 用户取消或超时关闭 |
| 1 已支付(待接单) | 2 制作中、5 已取消 | 商户接单 / 退款审核通过 |
| 2 制作中 | 3 配送中、5 已取消 | 骑手取餐 / 退款审核通过 |
| 3 配送中 | 4 已完成、5 已取消 | 用户确认收货 / 售后处理 |
| 4 已完成 | 无 | 终态 |
| 5 已取消 | 无 | 终态 |
实现上我一般把这张表收敛到一个 Map 里,写一个统一的更新方法:
private static final Map<Integer, List<Integer>> STATUS_FLOW = new HashMap<>(); static { STATUS_FLOW.put(0, Arrays.asList(1, 5)); STATUS_FLOW.put(1, Arrays.asList(2, 5)); STATUS_FLOW.put(2, Arrays.asList(3, 5)); STATUS_FLOW.put(3, Arrays.asList(4, 5)); } public void updateOrderStatus(Long orderId, Integer targetStatus, Long operatorId) { Order order = orderMapper.selectById(orderId); if (order == null) { throw new BusinessException("订单不存在"); } List<Integer> allowed = STATUS_FLOW.get(order.getStatus()); if (allowed == null || !allowed.contains(targetStatus)) { throw new BusinessException("非法状态流转: " + order.getStatus() + " -> " + targetStatus); } order.setStatus(targetStatus); orderMapper.updateById(order); }这个实现的要点:合法迁移集中在一个静态 Map 里,所有业务方法都走同一个更新入口,不存在某个 Service 方法绕过校验直接改 status 的可能性。参数说明:orderId 定位订单,targetStatus 是目标状态,operatorId 记录操作人用于审计日志。答辩时讲一句“状态机让新增流程只需要改 Map,不需要动业务代码”,设计模式的含金量就有了。
3.3 下单接口幂等性与事务边界控制
下单接口要防重复。用户网络抖动导致请求重试、用户手抖连点两次,没有幂等控制就会产生两笔订单。前端要做按钮置灰,后端也要兜底。兜底方案是幂等键:前端先调预下单接口获取 requestId,下单时携带这个 ID。后端用 Redis 的 SETNX 判断 requestId 是否已处理,已存在则直接返回第一次下单结果。
下单事务的边界要覆盖三件事:插入订单主表、插入订单明细表、扣减菜品库存。三件必须在一个事务里,任何一步失败都回滚。这里有一个容易被忽略的点:扣库存如果是先操作数据库再操作 Redis,事务提交时机和 Redis 操作之间就存在不一致窗口。常见做法是先扣 Redis 库存(返回扣减成功),再执行数据库下单事务,事务失败时补回 Redis 库存。
提示:幂等键在 Redis 里的 TTL 建议设为 10 分钟,覆盖一个完整的下单支付周期就够,设置太长会积累大量无用键。
4. Redis 在校园外卖订单并发场景的三个落点
4.1 菜品库存预扣的 Lua 原子脚本
校园外卖的菜品库存模型是“当日可售份数”:商户每天开店时初始化,卖完即止。用数据库乐观锁做库存扣减能实现,但高并发下 version 条件导致大量更新失败,用户体验差。我一般把库存放进 Redis String 类型,value 是剩余份数,用 Lua 脚本保证检查和扣减的原子性:
-- KEYS[1] = 库存键名, ARGV[1] = 本次扣减数量 local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then return -1 end if stock < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1脚本含义:GET 读取当前库存,键不存在返回 -1;库存不足返回 0;扣减成功返回 1。Lua 脚本在 Redis 中是原子执行的,整个脚本执行期间不会有其他客户端命令插入,所以“检查库存再扣减”不会被并发请求穿插。
三种库存方案的取舍对比:
| 方案 | 实现复杂度 | 一致性保证 | 适用场景 |
|---|---|---|---|
| 数据库乐观锁 | 低 | 强(有重试成本) | 低并发演示 |
| Redisson 分布式锁 | 中 | 强 | 中高并发 |
| Lua 原子扣减 | 中 | 强(无锁等待) | 高并发下单 |
Java 侧用 StringRedisTemplate 执行脚本,封装成独立方法:
@Resource private StringRedisTemplate stringRedisTemplate; private static final DefaultRedisScript<Long> DECREASE_STOCK_SCRIPT = new DefaultRedisScript<>( "local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then return -1 end if stock < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1", Long.class ); public int decreaseStock(String key, int count) { Long result = stringRedisTemplate.execute( DECREASE_STOCK_SCRIPT, Collections.singletonList(key), String.valueOf(count) ); return result == null ? -1 : result.intValue(); }不直接先 GET 再 DECR 的原因:两步操作之间会被并发请求插入——两个请求同时读到剩余 3 份、各扣 2 份,实际扣了 4 份而库存只剩 1 份,超卖就发生了。Lua 脚本把两步合并成一个原子操作,从根上避开问题。执行时注意第一个参数传入 key 列表,第二个参数是可变长的 ARGV,顺序和脚本里的 KEYS[1]、ARGV[1] 一一对应。
库存回滚的时序是另一个坑:Redis 扣减成功、数据库事务回滚时,要调用 INCR 回补库存;反过来先开事务再扣 Redis,Redis 失败时数据库事务已经提交,订单数据就脏了。这个先后顺序建议写成注释放在方法入口,防止后续维护的人改乱。
4.2 菜品列表缓存与缓存穿透空值处理
首页菜品列表是所有用户打开客户端看到的第一个接口,流量远高于其他接口。常见做法是加一层 Redis 缓存,key 设计为 dish:list:{merchantId},value 存菜品 JSON 数组,TTL 设 30 分钟。读取顺序是:先查缓存,命中直接返回;未命中查数据库,再回填缓存。
缓存穿透的场景是恶意请求或测试脚本反复查询不存在的商户 ID。数据库查不到记录,Redis 也不会写入,每个请求都打穿到数据库。毕设项目用布隆过滤器偏重,轻量做法是缓存空值:查询结果为空时也写一条空数组进 Redis,TTL 设 60 秒。这个窗口内的重复穿透请求都落在 Redis 上,数据库压力立刻降下来。代价是极端情况下商户新增菜品后 60 秒内客户端看不到,对毕设演示完全可以接受。
更新策略遵循 Cache Aside Pattern:先更新数据库,再删除缓存。不要先删缓存再更新数据库,否则更新期间会有请求读到旧数据并回填缓存,扩大不一致的窗口。
4.3 超时未支付订单的延迟关闭方案
每日总有用户下单后不支付,这些订单占着库存,必须有自动关闭机制。三套常见方案对比:定时任务每分钟扫数据库,实现简单但有至少一分钟的关闭延迟;Redis 过期事件监听,不可靠且不保证即时;延迟队列用 Redis ZSET 模拟,可控性最好。
具体实现:下单时把订单号作为 member、超时时间戳作为 score 写入 ZSET,另起一个定时任务每秒扫描 score 在当前时间之前的成员:
@Scheduled(fixedDelay = 1000) public void scanExpiredOrders() { long now = System.currentTimeMillis(); Set<String> expired = stringRedisTemplate.opsForZSet() .rangeByScore(ORDER_TIMEOUT_KEY, 0, now); if (expired == null || expired.isEmpty()) { return; } for (String orderNo : expired) { boolean closed = closeIfUnpaid(orderNo); if (closed) { stringRedisTemplate.opsForZSet().remove(ORDER_TIMEOUT_KEY, orderNo); } } }这段逻辑的说明:rangeByScore(0, now) 取出所有超时订单号;closeIfUnpaid 里先查数据库确认状态还是待支付,满足条件才改成已取消并回补 Redis 库存;处理成功的成员从 ZSET 中移除避免重复扫描。这里的关键原则是 Redis 只负责提醒“该检查了”,最终状态以数据库为准。
5. 答辩前的 JMeter 压测与 SpringBoot 高频坑位排查
5.1 200 并发下单的 JMeter 压测方法与指标解读
答辩最怕被问“系统能扛多少并发”。用 JMeter 提前给出一组数据,底气完全不同。配置方式是:线程组里设 200 个线程、Ramp-Up 5 秒、循环次数 10。HTTP 请求指向下单接口,Header 里加 Authorization 的 Bearer Token,token 用前置处理器从登录接口动态获取,避免所有请求共用同一个 token。
压测重点读三个指标:平均响应时间在 500ms 内合格;错误率必须为 0;吞吐量按请求总数除以总耗时估算,200 用户乘 10 次循环、耗时 30 秒左右,大致对应 60 到 70 的 TPS。这个数据写进论文测试章节,足够支撑“系统满足校园高峰期 200 人同时下单”的结论。
5.2 高并发场景的排查顺序
压测出现报错时按三层顺序排查:第一看日志有没有 HikariCP 连接池满的异常,默认最大连接数 10,200 并发下单瞬间就打满;第二看锁等待超时,订单状态更新是行锁操作,并发高时出现 Lock wait timeout exceeded,就把事务里非必要查询挪到事务外;第三看 Redis 连接池,Lettuce 连接池耗尽的表现是 RedisConnectionFailureException。
这三条路径基本覆盖了毕设系统压测失败的大部分场景。先调大连接池再观察,最后才考虑代码级优化,别一上来就改业务逻辑。
5.3 高频报错对照与缓存命中率自检
把做这个题目最容易踩的四个 SpringBoot 集成问题整理成对照表,排错时直接定位:
| 现象 | 根因 | 处理方式 |
|---|---|---|
| LocalDateTime 序列化成数组,前端 JSON 格式不对 | Jackson 对 Java 8 时间类型默认序列化不友好 | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 或全局配置 jackson.date-format |
| 连接 MySQL 报 Unknown database 或时区异常 | JDBC 缺时区参数 | URL 加 serverTimezone=Asia/Shanghai,库字符集设 utf8mb4 |
| 项目启动报循环依赖错误 | Spring Boot 2.6 起默认禁止循环依赖 | 把公共逻辑下沉到独立 Service,消除互相注入 |
| 订单列表数据过万后页面变慢 | 查询没走索引或缺联合索引 | EXPLAIN 看执行计划,确认 user_id + status 命中组合索引 |
压测之外值得做的一个验证:在 Redis 客户端里执行 INFO stats,看 keyspace_hits 和 keyspace_misses 两个指标。缓存命中率低于 60% 说明 key 设计或 TTL 设置有问题,可能是菜品更新时删缓存太频繁,也可能是 TTL 太短导致缓存频繁重建。把命中率数据记录到论文测试章节,比贴十张界面截图都有说服力。
本文还有配套的精品资源,点击获取