2026/10/6 14:33:00

Spring Boot外卖点餐系统设计与实现:订单状态机与库存防超卖实战

Spring Boot外卖点餐系统设计与实现:订单状态机与库存防超卖实战 简介一套基于Spring Boot的外卖点餐系统毕业设计资料包面向Java方向本科/高职毕业生提供从选题、开发到答辩的全流程支撑。系统角色覆盖用户前台、商家后台、管理员与骑手模块功能涉及商品管理、订单流转等典型业务场景适合作为课程设计或毕设二次开发基础。包内共638个文件以121个java源码、79个vue前端文件、72个html页面、64个js脚本为主另含SQL数据库脚本、doc论文文档、pptx答辩PPT、环境配置及一键安装/启动脚本整包25.24MB目录按论文章节组织便于对照学习。论文涵盖绪论、技术介绍、需求分析、系统设计ER图与数据表、系统实现及系统测试完整覆盖课题背景、研究意义、可行性分析、功能模块划分与测试结论技术栈涉及Spring Boot、JSP、MySQL和Tomcat。已有134人学习下载借助自带运行脚本和数据库脚本可快速导入运行结合完整设计文档直接用于毕业设计答辩准备。1. 别急着写页面先想清楚外卖点餐系统的订单状态是怎么流转的「springboot 外卖点餐系统设计与实现源码论文ppt答辩」这类题目是计算机毕设和 Java 课设里最不缺的选题几乎每个学校都有几个人在写同一个东西。它的外壳就是一套电商 CRUD菜品浏览、购物车加购、下单、订单管理。但真正动手之后你会发现难的不是增删改查而是订单状态怎么流转、库存什么时候扣、超时订单怎么取消、前端打包出来的静态资源怎么和 Spring Boot 融到一起。这篇文章就按这个顺序把这些点逐一讲透新手能照着跑通熟手能直接对照查漏正在准备毕业设计或课程设计的同学应该能省不少时间。2. 技术选型与数据库设计Spring Boot 版本怎么定、六张表怎么建2.1 前端用 Vue 还是 ThymeleafSpring Boot 用 2.x 还是 3.x做这个项目之前先回答两个选择题答案直接决定后面一个月的效率。第一个选择题前端方案。常见做法有两个方向。方向一是纯 Thymeleaf 服务端渲染页面模板放在 templates 目录里配合 Bootstrap、jQuery 写交互。这种方式整个项目只有一个 Spring Boot 工程不需要 Node 环境打包部署都很省事但页面观感比较「后台管理系统」菜品列表的加购交互做不出丝滑效果。方向二是前后端分离用 Vue 3 Vite Element Plus 单独建一个 frontend 目录后端只提供 JSON 接口开发时用 Vite 代理转发请求上线时把 Vue 构建出来的 dist 目录复制到 Spring Boot 的 static 目录下。我一般建议选第二种因为答辩演示效果好论文里也能多写一章前后端交互设计面试还能提一句「熟悉前后端分离开发模式」。代价是你必须搞懂「vue 打包放进 springboot」这条部署链路也就是第四章节要讲的内容。第二个选择题Spring Boot 版本。直接给结论用 2.7.18不要用 3.x。不是说 3.x 不好而是毕业设计的时间预算撑不起版本迁移的额外成本。Spring Boot 3.0 之后 Jakarta EE 取代了 javaxMyBatis-Plus 的老版本 starter 直接不兼容市面上大量教程、IDEA 插件、视频案例都基于 2.x。你如果一上来就配了个 3.2 的工程后面大概率会在「springboot 版本太高导致组件加载失败」这类问题上浪费时间。选 2.7.18你的 MyBatis-Plus、JWT、Springdoc 这些组件闭着眼睛都能找到匹配版本。这套项目的技术栈我通常按下面这个表来定你可以直接用组件选型说明后端框架Spring Boot 2.7.18稳定、生态全、教程多ORMMyBatis-Plus 3.5.x单表 CRUD 不用写 SQL逻辑删除开箱即用数据库MySQL 8.0免费、好装、答辩现场能用前端Vue 3 Vite Element Plus页面美观组件现成身份认证JWT无状态前后端分离友好权限Spring Boot 拦截器一个 HandlerInterceptor 就够不上 Spring Security 那套重东西这里要补一句为什么不上 Spring Security因为外卖点餐这种规模的系统只有两种角色用户和管理员Spring Security 的过滤器链、UserDetailsService、权限表达式这些概念对毕设来说太重了写多了反而答辩时讲不清。一个 JWT 拦截器加一个角色字段就能满足需求这是从业者视角的「够用就好」。2.2 六张核心表订单状态机是整张数据模型的心脏数据库设计决定了这套系统的上限。我见过不少人的点餐系统建了十几张表结果有一半是空的也有人只有三张表菜品、订单、用户硬把购物车塞进订单表。这个项目合理的表数量是六张用户表 user、菜品表 dish、分类表 category、购物车表 cart、订单表 orders、订单明细表 order_detail。user 表的核心字段是 username、password、phone、rolerole 用 tinyint 存0 是普通用户1 是管理员。dish 表要有 price 和 stock 两个字段这俩是后面库存控制的关键另外加一个 status 表示上架状态0 下架 1 上架而不是直接删记录。cart 表存 user_id、dish_id、quantity 和一个 checked 字段checked 表示这个购物车项在下单时是否被勾选这样能支持「选几个菜品下单」而不是一次清空。orders 表是核心字段包括 order_no、user_id、total_amount、status、address、phone、create_time、pay_time。最关键的是 orders.status 的取值约定。用 int 做状态机常见做法是这样0 待付款、1 待接单已付款、2 已接单制作中、3 配送中、4 已完成、-1 已取消。这套状态流转要统一收敛在 OrderService 里由 service 方法负责从 0 到 1、从 1 到 2 的转移而不是在 Controller 里随手 setStatus。答辩时这一条是可以拿出来讲的你用状态机约束了业务流转而不是让状态随便改。CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint NOT NULL, total_amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待付款 1待接单 2制作中 3配送中 4已完成 -1已取消, address varchar(255) NOT NULL, phone varchar(20) NOT NULL, remark varchar(255) DEFAULT NULL, create_time datetime NOT NULL, pay_time datetime DEFAULT NULL, deleted tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表 SQL 有两点值得注意。一是 status 的注释里已经把整个订单生命周期写清楚了数据库注释写详细一点后面写论文画状态图的时候能直接对照。二是索引只加了 user_id因为订单查询最核心的场景就是「某个用户的订单列表」没必要为了面面俱到建一堆用不上的索引。下单金额用 decimal(10,2)这个后面章节会讲原因。2.3 用 Maven 初始化工程一份能直接启动的 application.yml工程结构我习惯按下面的包来组织清晰而且答辩时好讲。controller 只做参数接收和结果包装service 管业务mapper 只碰数据库dto 管入参vo 管出参common 放统一返回体和异常。com.example.foodorder ├── common # Result、BizException、常量类 ├── config # 拦截器、跨域、JwtUtil 配置 ├── controller # 用户端和管理端接口 ├── service # 业务逻辑状态机流转都在这层 ├── mapper # MyBatis-Plus 的 Mapper 接口 ├── entity # 数据库表对应的实体类 ├── dto # 接收前端参数的类 └── vo # 返回给前端的视图类这里提一句网上很多源码的包结构是 controller、service、mapper、entity 四件套没有 dto 和 vo 的区分。毕设里把参数对象和返回对象分开写代码会显得更专业而且面试官问到分层设计时你有话可说。Maven 构建就用 spring-boot-starter-parent 做父工程Java 版本选 8 或 11不要选 17因为某些老版本的 Lombok 在 JDK 17 下会有兼容问题。然后是整套系统的入口配置文件我直接给出最小可用版本server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/food_order?useUnicodetruecharacterEncodingutf8serverTimezoneAsia%2FShanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这个文件里有三个参数经常让人踩坑。第一个是 serverTimezoneMySQL 8 的驱动默认要求指定时区不写会报 The server time zone value 的错写 Asia/Shanghai 就能避免。第二个是 map-underscore-to-camel-case它负责把数据库里的 create_time 自动映射成实体类的 createTime不配这个字段名就对不上。第三个是 logic-delete-field这是 MyBatis-Plus 的「后悔药」配好之后你调用 deleteById 时它实际执行的是 UPDATE 把 deleted 置为 1查询时自动过滤 deleted0避免物理删除数据导致答辩现场没法恢复。时间字段的 JSON 格式化也在这里统一处理了不然前端拿到的时间是个时间戳数字。端口这里预先提醒一句IDEA 新版编辑配置里可以直接指定启动端口但那个配置会覆盖 application.yml 吗实际是不会的yml 的优先级更高。所以你在 IDEA 的 Run Configuration 里改了端口发现没生效别怀疑是编译器缓存问题——先去翻 yml。这个话题后面避坑章节还会展开。3. 把「点餐」拆成可解释的业务链路购物车、下单事务、库存扣减与超时取消3.1 购物车加购后端必须做一次菜品状态和数量的兜底校验购物车接口看起来是最简单的很多人的实现就是 insert 一条记录进去前端点几次加购按钮数据库就多了几行。这种写法的问题在于同一道菜会被拆成多条重复记录而且完全没有校验菜品是否还在上架、库存还剩多少。页面上的按钮确实做了控制但接口是能被直接调用的Postman 一发请求就能绕过前端限制。我一般会在后端把加购逻辑写成「存在即累加不存在才新增」public void add(Long userId, Long dishId, Integer quantity) { if (quantity null || quantity 0) { throw new BizException(数量必须大于0); } // 先查菜品状态不对或已下架直接拒绝 Dish dish dishMapper.selectById(dishId); if (dish null || dish.getStatus() ! 1) { throw new BizException(菜品不存在或已下架); } // 累加后的数量不能超过库存 CartItem item cartMapper.selectOne(new LambdaQueryWrapperCartItem() .eq(CartItem::getUserId, userId) .eq(CartItem::getDishId, dishId)); if (item null) { item new CartItem(); item.setUserId(userId); item.setDishId(dishId); item.setQuantity(quantity); item.setChecked(1); cartMapper.insert(item); } else { int newQuantity item.getQuantity() quantity; if (newQuantity dish.getStock()) { throw new BizException(库存不足最多可加购 dish.getStock() 份); } item.setQuantity(newQuantity); cartMapper.updateById(item); } }这段代码的逻辑顺序是有讲究的。先校验参数再查菜品状态最后处理购物车行记录。为什么加购要先查一次菜品因为菜品可能在你浏览页面的时候刚好被管理员下架了库存也可能被其他订单扣成 0后端接口不能假设前端已经帮你做过这些检查。selectOne 的作用是判断购物车里有没有这个菜有就累加数量没有才新增行这样购物车列表里永远只有一行相同菜品前端展示时不用做合并逻辑。这里的 BizException 是自定义运行时异常message 会直接返回给前端弹窗提示所以提示语要写得像人话。购物车这层还有一个常见缺陷很多人把 checked 字段忘了或者用 checked0 表示勾选、checked1 表示没勾语义反了。建议约定 checked1 表示选中、checked0 表示未选中下单时只取 checked1 的记录用户可以通过勾选来控制一次下单覆盖哪些菜品。3.2 下单事务扣库存和生成订单必须放在同一个事务里下单是这套系统的核心链路也是答辩时最容易暴露水平的地方。一个完整的点餐订单流程是查询购物车勾选项、校验菜品状态、扣减库存、生成订单主表、生成订单明细、清空购物车。这几步操作了多张表任何一步失败都不能留下半截数据。扣了库存但订单没生成库存就凭空少了订单生成但购物车没清空用户下次再点就重复下单了。常见的正确做法是把这几步包装在一个事务方法里库存扣减用条件更新来防超卖Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, ListLong cartItemIds) { // 1. 查出勾选的购物车项 ListCartItem cartItems cartMapper.selectList(new LambdaQueryWrapperCartItem() .eq(CartItem::getUserId, userId) .in(CartItem::getId, cartItemIds) .eq(CartItem::getChecked, 1)); if (cartItems.isEmpty()) { throw new BizException(请先勾选要下单的菜品); } // 2. 遍历扣库存金额以后端算出来的为准 BigDecimal total BigDecimal.ZERO; for (CartItem item : cartItems) { Dish dish dishMapper.selectById(item.getDishId()); if (dish null || dish.getStatus() ! 1) { throw new BizException(菜品已下架请刷新购物车); } // 条件更新stock 数量才允许扣减返回 0 表示扣失败 int updated dishMapper.deductStock(item.getDishId(), item.getQuantity()); if (updated 0) { throw new BizException(库存不足 dish.getName()); } total total.add(dish.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 3. 生成订单主表和明细表 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(total); order.setStatus(0); order.setCreateTime(LocalDateTime.now()); orderMapper.insert(order); for (CartItem item : cartItems) { OrderDetail detail new OrderDetail(); detail.setOrderId(order.getId()); detail.setDishId(item.getDishId()); // 明细里的名称和价格要冗余一份订单不能依赖菜品表的实时变动 detail.setDishName(item.getDishName()); detail.setPrice(item.getPrice()); detail.setQuantity(item.getQuantity()); orderDetailMapper.insert(detail); } // 4. 清掉已下单的购物车项 cartMapper.delete(new LambdaQueryWrapperCartItem() .eq(CartItem::getUserId, userId) .in(CartItem::getId, cartItemIds)); return OrderVO.from(order); }这段代码里至少有四个点值得展开。第一个是 Transactional(rollbackFor Exception.class) 的 rollbackForSpring 的事务默认只回滚 RuntimeException 和 Error如果你抛的是自定义的 checked Exception事务不会回滚。把 rollbackFor 写成 Exception.class 是最稳妥的写法这也是新手最容易忽略的地方。第二个是 deductStock 这个方法它在 Mapper 里对应一条 UPDATE 语句UPDATE dish SET stock stock - #{qty} WHERE id #{dishId} AND status 1 AND stock #{qty}注意 WHERE 条件里带上了 stock #{qty}这样并发情况下两个请求同时扣最后一份库存时数据库的行锁会让后到的那个请求更新 0 行代码里判断 updated 0 就能精准提示「库存不足」而不是先查出库存再在 Java 里 if 判断——那种写法在并发下一定会超卖。第三点是金额计算放在后端用 BigDecimal 而不是 doubledouble 算金额会出现 0.1 0.2 0.30000000000000004 这种问题答辩时被问到金额精度怎么处理这就是标准答案。第四点是订单明细冗余了菜品的名称和价格快照为什么要冗余因为菜品表的价格和管理员后续修改历史订单的金额不能被牵连改动。这一个细节写进论文可以当作「数据一致性设计」亮点。注意下单接口不要接收前端传过来的总金额前端只能传购物车项 ID 列表金额必须由后端根据数据库里的菜品价格重新计算。这个设计既能防止用户篡改金额也是答辩时比较加分的回答。3.3 支付模拟与订单超时状态机在定时任务里的用法真正接微信支付、支付宝对毕设来说成本太高而且个人开发者很难申请到商户号所以常见做法是做一个模拟支付接口用户点击「去支付」后后端把订单状态从 0 改成 1写一条 pay_time前端再跳转到「等待商家接单」页面。这个模拟接口的代码很简单就是 update 订单状态但话术上要写成「预留了第三方支付平台的对接位置」。论文里可以在接口设计部分画一个支付流程图说明真实场景下这里是回调第三方支付结果而不是前端直接调用。模拟支付之外的另一个重点是超时订单的自动取消。用户下单待付款但不付款订单会一直占着库存其他人就买不到了。最简单的解法是 Spring Task 定时扫描Scheduled(cron 0 0/5 * * * ?) Transactional(rollbackFor Exception.class) public void cancelTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(10); ListOrder timeoutOrders orderMapper.selectList(new LambdaQueryWrapperOrder() .eq(Order::getStatus, 0) .lt(Order::getCreateTime, deadline)); for (Order order : timeoutOrders) { // 条件更新只有当前状态仍为 0 才能改成 -1防止和用户手动取消并发冲突 int affected orderMapper.update(null, new LambdaUpdateWrapperOrder() .eq(Order::getId, order.getId()) .eq(Order::getStatus, 0) .set(Order::getStatus, -1)); if (affected 1) { restoreStock(order.getId()); } } }这个定时任务的几个细节要注意。cron 表达式0 0/5 * * * ?表示从第 0 秒开始每 5 分钟执行一次实际项目里可以调成 30 秒扫一次让演示效果更明显。超时时间用 create_time 往前推 10 分钟而不是前端页面定时器来触发因为前端关掉页面之后定时器就不跑了必须后端兜底。最难的部分是恢复库存和取消订单的并发一致性如果用户同时点了「取消订单」按钮定时任务也在跑两次都把状态改成 -1库存就会被恢复两次。这里的解法就是用条件更新UPDATE orders SET status -1 WHERE id ? AND status 0只有影响行数为 1 才去恢复库存这样同一笔订单的取消动作只会执行一次。定时任务方法上也要加 Transactional因为恢复库存涉及订单明细表和菜品表的多表操作中间任何一步失败都要回滚不能让订单取消成功但库存没加回来。这个「条件更新保证幂等」的思路和 3.2 里扣库存用的条件更新是同一种思想。论文的系统设计章节画订单状态图时0 到 -1 这条线要标明「用户主动取消」和「超时自动取消」两个触发器这就是状态机的价值。4. vue 打包放进 springboot 的静态资源目录部署路径与接口守卫4.1 构建与放置dist 目录怎么进 static、路由 404 怎么救前后端分离开发时浏览器访问的是 Vite 的 5173 端口接口由 Vite 代理转发到 8080。但答辩演示时不能让两台服务都开着更合理的做法是把前端构建产物放进 Spring Boot让整个系统只跑一个 Jar。这一步就是网上很多人搜的「vue 打包放进 springboot」用 npm run build 构建出 dist 目录然后把 dist 里的文件复制到后端工程的 src/main/resources/static 下重新打包启动后直接访问 localhost:8080 就能看到页面。cd frontend npm run build rm -rf ../src/main/resources/static/* cp -r dist/* ../src/main/resources/static/ cd .. mvn clean package -DskipTests java -jar target/food-order-0.0.1-SNAPSHOT.jar构建前有几个配置必须确认。Vite 的 vite.config.js 里要设置 base: ./这样打包出来的 index.html 里引用的 js、css 路径是相对路径而不是以 / 开头。为什么如果后面用 Nginx 部署在子路径下绝对路径的 /assets/index.js 会直接 404。开发时代理配置是 proxy 把 /api 转发到 8080但打包后不存在 Vite 代理了前端请求直接打到同源的 8080所以接口地址不能写死成 http://localhost:8080要用相对路径 /api/xxx 或者一个环境变量来切换。这是前后端分离项目最常见的脆皮点之一。路由模式也会在这里出问题。Vue Router 默认是 createWebHistory地址栏长这样 /order/list直接刷新页面时后端没有对应的路由处理器Spring Boot 会返回 404。我一般建议毕设直接改用 createWebHashHistory地址变成 /#/order/list刷新时永远不会 404代价是地址栏有个 # 不太好看。如果你确实要用 history 模式后端得补一个兜底转发Controller public class PageForwardController { // 前端路由的路径由这里兜底转发到 index.html注意不能拦截 /api/** GetMapping(value {/, /index.html, /order/**, /cart/**, /user/**}) public String forward() { return forward:/index.html; } }这段代码的原理是Spring Boot 收到 /order/xxx 的 GET 请求后把它转发给 static 目录下的 index.htmlVue Router 接管路由并渲染对应页面。两个需要注意的边界一是路径列表里不能包含 /api/接口请求必须走 Controller二是 post 请求不能被这种兜底拦截但页面导航本来就是 GET问题不大。实际操作中我还是推荐 hash 模式少一个潜在坑演示效果也没差别。4.2 JWT 拦截器与 CORS谁在守卫点餐接口系统里有用户端和管理端菜品浏览和加购可以不需要登录但下单、查看订单、管理员后台这些接口必须校验身份。常见的做法是 JWT登录成功时后端生成 token 返回给前端前端把 token 存在 localStorage之后每次请求都在 Authorization 请求头带上后端用一个拦截器统一校验。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 跨域预检请求直接放行 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BizException(401, 未登录或登录已过期); } // 解析失败会抛异常由全局异常处理器转成 401 LoginUser user JwtUtil.parseToken(token.substring(7)); request.setAttribute(userId, user.getUserId()); request.setAttribute(userRole, user.getRole()); return true; } }拦截器的注册路径要注意。如果你是让所有业务接口都带 /api/ 前缀那拦截器路径就是 addPathPatterns(/api/**)放行名单里通常包括登录接口、注册接口、菜品查询接口和分类查询接口。因为用户没登录也需要能浏览菜单不然登录页面都渲染不出来。排除项这样写registry.addInterceptor(new JwtInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns( /api/user/login, /api/user/register, /api/dish/**, /api/category/** );配置类里还需要处理另一个问题CORS 跨域。开发环境下前端跑 5173、后端跑 8080端口不同就属于跨域。虽然前面配了 Vite 代理可以规避但为了留一手后端仍然加上跨域放行Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); }这段配置里有一个新手很容易踩的细节allowedOriginPatterns() 和 allowCredentials(true) 是允许搭配的但 allowedOrigins() 和 allowCredentials(true) 搭配会被浏览器直接拒绝。因为带凭证的跨域请求不允许使用通配符来源必须用 Pattern 形式。如果你看到浏览器报「The value of the Access-Control-Allow-Origin header must not be the wildcard *」这类错十有八九是这个原因。4.3 接口 404 与上下文路径一类隐蔽报错的标准排查顺序好代码合并到 Spring Boot 之后最隐蔽的坑就是静态资源能打开但接口全部 404。这里说的 404 不是接口不存在而是请求路径没对上。现象是首页能显示、图片能加载但点登录按钮疯狂 404。下面按排查顺序列每一步都验证过再走下一步。先看 target/classes/static 目录里有没有 index.html确认构建产物真的复制进了编译目录。IDEA 有时候不刷新 resources要手动 clean 一下再重新跑。再在浏览器开发者工具里看 Network找到登录请求看它完整的 URL 是什么。如果前端发出的路径是 /api/user/login而后端接口类上写的是 RequestMapping(/user)那就对不上。然后检查接口路径是不是多了或者少了前缀。前端要统一走 /api/ 前缀还是直接裸路径前后端必须一致没有捷径就是约定。最后看有没有配置 server.servlet.context-path。这个配置一旦启用所有路径都会带前缀比如 /api 变成 /api/api前端、拦截器、静态资源全部要跟着改是蚌埠级的麻烦。毕设阶段我的建议是不用 context-path接口统一在类名上加 RequestMapping(/api/xxx)可控得多。这一套排查下来能覆盖九成以上的 404 问题。剩下的玄学场景比如改了前端代码但页面没变基本是浏览器缓存CtrlShiftR 强刷新就能解决。5. 避坑这套系统翻车率最高的 5 个地方按现象排查5.1 springboot 版本太高导致 MyBatis-Plus 加载失败现象新建工程时选了 Spring Boot 3.xpom 里引了 mybatis-plus-boot-starter旧版 3.4.x项目启动直接报 ClassNotFoundException 或者 MybatisPlusAutoConfiguration 加载失败。原因Spring Boot 3.0 把 javax.servlet 迁移到了 jakarta.servlet而旧版 MyBatis-Plus 的自动配置类里还是引入 javax 的类类名都不存在了自然加载不起来。这不是配置写错是版本生态的断代。解决两条路要么把 Spring Boot 降回 2.7.x要么用 MyBatis-Plus 官方提供的 mybatis-plus-spring-boot3-starter 并升到 3.5.5 以上。我直接建议前者因为你的时间要留给业务而不是折腾依赖。选 2.7.18 之后所有组件都能闭眼匹配。5.2 IDEA 里改了端口不生效或者端口被占用现象在 IDEA 右上角的 Run Configuration 里把端口改成 8081启动后日志还是显示 Tomcat started on port 8080或者直接报 Port 8080 was already in use。原因第一个现象的真相是IDEA 的编辑配置里那个端口参数优先级低于配置文件Spring Boot 默认以 application.yml 里的 server.port 为准你在 IDEA 可视化界面里改的其实是运行时参数不一定能覆盖 yml。第二个现象是上一个没关干净的后端进程还占着端口。解决要改端口就去改 application.yml不要依赖 IDEA 界面。改完执行 mvn clean 再启动。端口被占用时查出 PID 干掉再重启lsof -i:8080 kill -9 PID这两个问题是毕设演示现场翻车率最高的前两名直接背下来能省不少时间。5.3 Lombok 的 Data 在实体类上不生效现象实体类上写了 Data但 controller 或 service 里调用 getter 时编译直接飘红提示找不到方法 getUserId。原因IDE 里 Lombok 插件没装或者 Annotation Processing 没开启。IDEA 默认是开了的但不排除你用的是精简版或者刚换了 IDE。还有一种情况是 JDK 版本和 Lombok 版本不匹配新 JDK 要用新版 Lombok。解决先装 Lombok 插件然后到 Settings - Build - Compiler - Annotation Processors 里确认 Enable annotation processing 打勾。如果是 Maven 构建pom 里确认有 lombok 依赖且 scope 是 provided。JDK 17 的话把 lombok 升到 1.18.30 以上。这个坑本身不难但会让你在答辩前夜怀疑人生。5.4 下单接口抛异常但库存没回滚表引擎是 MyISAM现象下单时故意制造异常前端报错但打开数据库一看菜品的 stock 被扣了orders 表里却没有对应的订单记录。数据对不上。原因有个非常隐蔽的细节——建表时用的 SQL 脚本里带了 ENGINEMyISAM。MyISAM 是不支持事务的Transactional 注解在它面前等同废纸插入和更新根本不会回滚。这个坑多见于从网上抄来的旧脚本或者课程设计里的模板数据库。解决把所有业务表改成 InnoDBALTER TABLE dish ENGINEInnoDB; ALTER TABLE orders ENGINEInnoDB; ALTER TABLE order_detail ENGINEInnoDB;顺手用下面这条 SQL 检查一下确认没有遗留的 MyISAM 表SHOW TABLE STATUS WHERE Engine ! InnoDB;这个坑的血泪经验是事务不回滚时先查数据库引擎别急着查代码。5.5 并发下单把库存扣成负数现象商品库存只剩 1 份两个用户同时点下单后端两个请求都返回成功数据库里 stock 变成了 -1。原因代码里先 select 查库存Java 里判断 stock 0再 update。两个请求同时查到 stock1都通过了 Java 判断然后依次执行 update最后结果就是负数的。经典的 read-then-write 并发问题出在「先查后改」的模式上。解决不用查了再改改成条件更新。前面 3.2 的 deductStock 已经是标准答案UPDATE dish SET stock stock - 1 WHERE id ? AND stock 1返回影响行数为 0 就提示库存不足。单体项目用这种方式完全够用不需要上分布式锁答辩也不用往复杂了吹。6. 论文与 PPT 答辩把单体系统讲出业务深度的一小时6.1 论文主线怎么写这套项目的论文结构直接按「需求分析 - 系统设计 - 数据库设计 - 系统实现 - 系统测试」五章走这是评审老师最熟悉、最不出错的框架。真正要下功夫的是把「亮点」写进设计章节订单状态机的流转设计画清楚库存防超卖的条件更新讲明白订单超时取消的定时任务画一个时序图。这三个点每个写两页论文的技术深度就立住了。测试章节不要只写单元测试要写接口测试用一个接口测试表格列出接口名、请求参数、预期结果、实际结果这个表比十页代码更能让老师相信你做了验证。6.2 PPT 演示顺序与答辩即兴问题PPT 演示顺序比 PPT 本身更重要。我梳理了一套保底顺序演示节点操作内容讲解要点主流程用户注册登录、浏览菜品、加购、下单、模拟支付强调订单状态从 0 到 1 的流转商家接单管理员登录、接单、把订单推进到配送中展示第二个角色的权限差异异常场景把某个菜品库存改少再尝试下单讲库存条件更新的作用超时取消下个单不支付等定时任务扫描讲状态机与定时任务三个必被追问的问题提前备好第一「你毕业设计遇到的最大困难是什么」——标准答法是库存扣减的并发控制讲从 select 判断改成条件更新的过程。第二「为什么用 MyBatis-Plus 不用 JPA」——答单表 CRUD 代码量少、逻辑删除内置、和导师用的技术栈一致。第三「项目上线会考虑哪些优化」——答Redis 缓存菜品列表、订单超时用延迟队列替换定时扫描、支付对接微信官方接口。新手到这里很容易被追问细节记得反问自己延迟队列真的了解吗不了解就回答「这是改进方向还没有深入研究」诚实反而加分。我当年 PPT 做得像功能截图合集被评委一句「库存怎么防超卖」当场问住后来吸取的教训是一个系统只需要准备三个能讲透的技术点剩下的时间全部留给演示主流程。希望这篇文章能让你少走这段弯路希望帮到你。本文还有配套的精品资源点击获取