
简介基于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 -jarMyBatis 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参数接收、参数校验、结果包装禁止写金额计算、禁止直接操作 MapperService业务编排、事务控制、状态校验禁止出现 Web 层对象MapperSQL 执行、单表 CRUD、复杂查询禁止包含业务判断请求从入口到落库的链路是前端 JSON 到达 Controller转成 DTO 后调用 ServiceService 内部开启事务完成数据校验后逐条写入数据库响应统一返回 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;这段 SQL 的逻辑说明orders 表的 status 用 TINYINT 存数字状态0 到 5 对应订单生命周期里的六个状态枚举含义写在注释里代码里可以用常量类对应order_item 冗余了 dish_name 和 price 两个字段这是刻意做快照——菜品改价或下架后历史订单仍能按当时的价格和名称展示。这个设计点叫“历史数据不可变”论文里可以单独展开。注意两张表之间只用索引不用物理外键。高并发写入场景下物理外键每次插入都要做约束检查拖慢事务数据一致性交给 Service 层事务控制由程序保证而不是数据库兜底。创建时间和更新时间推荐交给 MyBatis Plus 的自动填充处理不用在每个 Service 方法里手动 set。实现方式是注册一个 MetaObjectHandlerComponent 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 管理后台服务端不依赖 Cookietoken 放在请求头里就能跨端生效。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 放进 subjectrole 声明里存放角色名过期时间设为 2 小时parseToken 负责解析和签名校验签名不一致会直接抛异常调用方不需要自己判断。密钥这里写常量只是为了演示真实项目要放到 application.yml 或环境变量里注入别让密钥进代码库。校验链路需要一个拦截器在请求进入 Controller 之前解析 tokenComponent 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 MapInteger, ListInteger 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(订单不存在); } ListInteger 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 DefaultRedisScriptLong 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 回补库存反过来先开事务再扣 RedisRedis 失败时数据库事务已经提交订单数据就脏了。这个先后顺序建议写成注释放在方法入口防止后续维护的人改乱。4.2 菜品列表缓存与缓存穿透空值处理首页菜品列表是所有用户打开客户端看到的第一个接口流量远高于其他接口。常见做法是加一层 Redis 缓存key 设计为 dish:list:{merchantId}value 存菜品 JSON 数组TTL 设 30 分钟。读取顺序是先查缓存命中直接返回未命中查数据库再回填缓存。缓存穿透的场景是恶意请求或测试脚本反复查询不存在的商户 ID。数据库查不到记录Redis 也不会写入每个请求都打穿到数据库。毕设项目用布隆过滤器偏重轻量做法是缓存空值查询结果为空时也写一条空数组进 RedisTTL 设 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(); SetString 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 Tokentoken 用前置处理器从登录接口动态获取避免所有请求共用同一个 token。压测重点读三个指标平均响应时间在 500ms 内合格错误率必须为 0吞吐量按请求总数除以总耗时估算200 用户乘 10 次循环、耗时 30 秒左右大致对应 60 到 70 的 TPS。这个数据写进论文测试章节足够支撑“系统满足校园高峰期 200 人同时下单”的结论。5.2 高并发场景的排查顺序压测出现报错时按三层顺序排查第一看日志有没有 HikariCP 连接池满的异常默认最大连接数 10200 并发下单瞬间就打满第二看锁等待超时订单状态更新是行锁操作并发高时出现 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 加 serverTimezoneAsia/Shanghai库字符集设 utf8mb4项目启动报循环依赖错误Spring Boot 2.6 起默认禁止循环依赖把公共逻辑下沉到独立 Service消除互相注入订单列表数据过万后页面变慢查询没走索引或缺联合索引EXPLAIN 看执行计划确认 user_id status 命中组合索引压测之外值得做的一个验证在 Redis 客户端里执行 INFO stats看 keyspace_hits 和 keyspace_misses 两个指标。缓存命中率低于 60% 说明 key 设计或 TTL 设置有问题可能是菜品更新时删缓存太频繁也可能是 TTL 太短导致缓存频繁重建。把命中率数据记录到论文测试章节比贴十张界面截图都有说服力。本文还有配套的精品资源点击获取