
简介《基于Spring Boot的校园食堂订餐系统的设计与实现》是一份完整的毕业设计论文文档主要面向计算机、软件工程等相关专业学生也适合Java Web学习者作为项目参考。文档针对传统食堂窗口排队拥挤、学生外卖依赖高等问题设计了基于Spring Boot Vue.js uni-app的订餐系统涵盖手机端菜品浏览、在线提交与改撤订单、一卡通移动支付、菜品销售统计等功能并详细说明了B/S架构、MySQL数据库和IntelliJ IDEA、WebStorm等开发工具的实际运用。资源以单个doc文档形式打包压缩包大小3.32MB正文包含中英文摘要、关键词、系统设计、开发实现、测试调试与总结展望结构完整便于直接阅读、打印或参考论文框架。该资源发布以来已有9245人学习浏览对于需要完成校园服务类毕业设计或快速了解Spring Boot实际开发流程的读者具有较好的参考价值。1. 为什么 Spring Boot 是校园食堂订餐系统最省心的技术底座不管你是做毕业设计、课程设计还是实验室里临时接的“帮忙做个点餐系统”的活儿只要场景落在“校园食堂”里Spring Boot 几乎就是绕不开的第一选择。原因很简单它把 Web 开发里最琐碎的配置全部收进 starter一个main方法就能把 Tomcat、DispatcherServlet、JSON 序列化一起带起来让开发者的精力集中在订餐业务本身而不是耗在 xml 配置和容器部署上。这个系统的本质是“一个面向学生的点餐前端 一个面向食堂管理员的运营后台 一套订单状态流转”。它不像电商那么重但麻雀虽小五脏俱全用户注册登录、菜品展示、加购下单、订单管理、后台菜品上下架都是实打实要落地的功能。能把这个闭环跑通你就已经摸清了 Java Web 开发 90% 的日常套路。这篇文章我会按自己平时做这类系统的顺序来走先拆需求和技术选型再给数据库设计然后是后端核心代码和参数说明最后是那些最容易翻车的地方。适合刚学完 SSM 或 Spring Boot 基础、想完整跑通一个项目的读者也适合给论文里“系统实现”章节找素材的同学。2. 先拆清楚再动手订餐系统的角色、流程与选型依据很多同学拿到这类题目第一反应是打开 IDEA 新建项目这其实是最容易返工的做法。我习惯先画一张功能脑图把所有角色、页面、状态变化列在一张表里确认哪些是“必须要做”哪些是“可以做但不影响答辩”。这一章就把拆解结果和选型理由一起讲清楚。2.1 三个角色和一个完整订单生命周期校园食堂订餐系统的角色基本是固定的三类学生下单者、食堂管理员接单者、系统管理员维护者。学生负责浏览菜品、加入购物车、下单支付在课设里通常直接用模拟支付管理员负责菜品上下架、查看订单、处理出餐系统管理员负责用户管理和基础数据维护。订单生命周期是这个系统的核心也是论文里“流程设计”章节最好画的部分。我一般会定义这些状态待支付、已支付/待取餐、制作中、已完成、已取消。有人还会加“待评价”但如果你的系统不是为了做评价功能建议去掉因为评价格外引入一张表和一组接口对核心流程没有任何帮助。2.2 功能清单要按“核心功能优先”排序刚才说过功能不是越多越好而是越贴近闭环越好。以下是这类系统最常见的功能拆法直接照着用也可以。核心功能用户注册登录、菜品列表与分类筛选、加入购物车、生成订单、订单状态流转、用户订单历史、管理员登录、菜品上下架、订单状态更新。可选功能菜品搜索、每日推荐、收藏夹、公告栏、评价、图表统计、定时自动关单。我的建议是如果时间只有两到三周就老老实实做核心功能如果有充足时间把“定时自动关单”和“图表统计”加进来。前者能在答辩里讲“我用了延迟队列/定时任务解决超时订单”后者能讲“我用 ECharts 统计了食堂销量”都是加分项而且实现难度不高后面会提到怎么加。2.3 为什么是 Spring Boot MyBatis Plus MySQL而不是别的选型这一节不太会被单独提问但导师很可能随意问一句“为什么用这个技术”所以还是能说清楚为好。Spring Boot 负责的 Web 层、控制反转和自动配置是最不费脑的部分持久层我一般选 MyBatis Plus 而不是原生 MyBatis原因是它内置了BaseMapper单表 CRUD 不需要写 XML这对这种以单表操作为主的系统来说能省下不少时间数据库用 MySQL 是因为本地环境最容易跑而且网上遇到报错时随手一查就有答案。这套组合的特点是“上限不高但下限很低”也就是说你不需要花太多时间在技术细节上就能把业务跑通。如果你手头熟悉 JPA也可以用 JPA 替换 MyBatis Plus但我不推荐在课设里用 JPA它的懒加载和 N1 查询问题在答辩时容易被问住而 MyBatis Plus 的代码生成器和条件构造器对新人更友好。2.4 中间件能省则省Redis 不是必需品很多参考代码一上来就是 Redis 做缓存、RabbitMQ 做异步这与其说是技术需求不如说是简历膨胀的需求。对一个校园食堂订餐系统来说并发量极低菜品数据变化不频繁Redis 缓存的意义聊胜于无消息队列更是负资产只会增加部署和调试成本导致你必须多维护一个服务。唯一值得引入的是“定时任务”。如果要做超时未支付自动关单Spring Boot 自带Scheduled注解就能搞定不需要额外引入 xxl-job 或 Quartz。不过Scheduled是单机内存调度集群部署会重复执行这个问题在课设里基本不存在但你要是写到论文里建议提一句“当前实现基于单机部署生产环境需引入分布式锁”既显得专业又守住了诚实。关系型数据库就选 MySQL 5.7 或 8.0本地开发时用 root 账号加简单密码即可部署到服务器时再收紧账号权限。3. 把数据表设计好五张核心表的 Schema 与状态字段设计数据库设计是这类系统里最该花时间的一步因为一旦表结构定下来后面的代码只是“照表操作”。我见过很多翻车案例都是因为订单表里直接存了一串菜品 ID 和数量的逗号分隔字符串结果后期想统计销量、按分类汇总时完全没法写 SQL。所以这一章给出一个可以直接用的标准 Schema。3.1 用户表user用户表是三张表里最简单的核心字段是用户名、密码、手机号和角色。角色字段用tinyint或int而不是字符串是为了后续扩展角色时不用改表结构。密码必须加密存储我用 BCryptSpring Security 的BCryptPasswordEncoder可以直接拿来用不要写明文密码这一点在文档和安全检查里都会被提到。建表语句如下CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(32) NOT NULL COMMENT 用户名, password varchar(64) NOT NULL COMMENT BCrypt加密后的密码, phone varchar(16) DEFAULT NULL COMMENT 手机号, role tinyint(4) NOT NULL DEFAULT 0 COMMENT 角色: 0-学生 1-管理员, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;这里有两个细节要注意。第一个是utf8mb4而不是utf8因为utf8在 MySQL 里存不了 emoji 表情如果昵称或菜品描述里出现表情符号会直接报“Incorrect string value”错误。第二个是用户名必须加唯一索引注册接口里虽然做了校验但并发情况下可能出现两个相同用户名同时注册成功唯一索引是最后一层防线。3.2 菜品表dish与菜品分类表category菜品分类我建议独立成一张表而不是在菜品表里直接写死一个分类名。原因是分类是可变的比如“新品推荐”和“时令限定”这类分类会被频繁修改独立成表后的修改成本只是更新一行数据。菜品表不需要存分类名只需存分类 ID查询时用JOIN或者代码里二次查询补上名称即可。菜品表加了一个status字段1 表示上架0 表示下架。删除操作尽量不要物理删除而是下架因为历史订单中如果引用了菜品 ID物理删除后关联会变得混乱。这个设计在答辩时也能解释“我做了软删除而不是物理删除”体现出对数据完整性的考虑。CREATE TABLE category ( id int(11) NOT NULL AUTO_INCREMENT, name varchar(32) NOT NULL COMMENT 分类名称, sort int(4) DEFAULT 0 COMMENT 排序值, 越小越靠前, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品分类表; CREATE TABLE dish ( id int(11) NOT NULL AUTO_INCREMENT, category_id int(11) NOT NULL COMMENT 所属分类ID, name varchar(64) NOT NULL COMMENT 菜品名称, price decimal(10,2) NOT NULL COMMENT 价格, 单位元, image varchar(255) DEFAULT NULL COMMENT 图片相对路径, description varchar(255) DEFAULT NULL COMMENT 菜品描述, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 上下架: 1-上架 0-下架, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品表;价格用decimal(10,2)而不是float或double。float在计算金额时存在精度丢失问题比如 19.9 实际存储可能是 19.8999996虽然看起来差的不多但累计求和时误差会被放大。更关键的是如果你在答辩时被问到“金额字段的类型”答案只有decimal才说得过去。3.3 订单表与订单明细表核心业务的一对多模型订单和订单明细的关系是“一对多”一个订单有多个菜品每个菜品对应一条明细记录。为什么要拆成两张表而不是在订单表里用一个字段存 JSON因为 JSON 字段无法在 SQL 里做聚合统计。当你需要查“本周哪个菜卖得最好”时明细表一条GROUP BY就出来了而用 JSON 存储的方案要做复杂解析效率差且不可维护。另外订单表里不能只存总价而不存明细否则后续做退款或售后时没有依据但这在后续代码实现里会展开。订单表设计为三个用户可感知的部分基础信息下单人、金额信息总价、状态信息。下面给了成品 SQL你可以直接用CREATE TABLE orders ( id int(11) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id int(11) NOT NULL COMMENT 下单用户ID, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态: 0-待支付 1-待取餐 2-制作中 3-已完成 4-已取消, remark varchar(255) DEFAULT NULL COMMENT 用户备注, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL COMMENT 支付时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表; CREATE TABLE order_detail ( id int(11) NOT NULL AUTO_INCREMENT, order_id int(11) NOT NULL COMMENT 订单ID, dish_id int(11) NOT NULL COMMENT 菜品ID, dish_name varchar(64) NOT NULL COMMENT 下单时的菜品名称快照, price decimal(10,2) NOT NULL COMMENT 下单时的价格快照, quantity int(11) NOT NULL DEFAULT 1 COMMENT 购买数量, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;这里有一个特别值得说明的字段dish_name和price。它们叫“快照字段”保存的是下单那一刻的菜名和价格而不是用菜品 ID 去实时关联菜品表。原因很直观如果食堂管理员修改了菜品名称或价格历史订单里的名称和价格也不应该跟着变。这是订单系统设计和业务系统设计的一个明显区别也是能让导师看到你考虑过数据一致性的细节。3.4 订单状态字段设计用数字而不是字符串订单状态status用tinyint存储含义写在注释里。这样的好处是数据库层面不依赖字符串查询效率更高也不容易出现“已支付”和“已付款”这种同名不同值的问题。状态码对应的文字解释放在代码枚举里维护后端文档和前后端协商都以枚举为准。状态流转逻辑是这样的下单后为 0待支付用户点击支付后变为 1待取餐食堂管理员接单后改为 2制作中出餐后改为 3已完成用户在待支付状态下取消则变为 4已取消。需要注意状态之间不是随意跳转的比如“制作中”不能直接跳到“待支付”这不合理。这个校验放在 Service 层后面给出代码。4. 让业务跑起来Spring Boot 后端核心代码与配置参数表结构定下来之后后端的开发顺序就非常规律了写实体类、写 Mapper、写 Service、写 Controller。为了控制篇幅这一章不会把所有代码贴一遍那样反而看不完我选了三个最有代表性、也最容易出问题的环节来讲项目初始化与配置、用户登录签发 JWT、下单接口的事务处理。4.1 项目初始化的最小配置pom.xml 与 application.yml创建 Spring Boot 项目时我建议直接使用 Spring Initializr 勾选 Web、MySQL Driver、MyBatis Plus 三个依赖。如果网络环境不好也可以手动在 pom.xml 里补上这段配置parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies版本号这块可以注意一下Spring Boot 2.7 是 2.x 系列的最后一个维护版本用的人多、遇到问题搜得到当作课设和毕设来用最稳妥。如果你选了 Spring Boot 3.x要注意它基于 Jakarta EE很多旧教程里的javax.persistence、javax.servlet包名需要对应替换成jakarta这部分容易在跑旧代码时卡住。application.yml 里的配置比较常规但有三个参数值得单独说明server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/canteen?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto第一数据库连接串里的serverTimezoneAsia/Shanghai一定要加。MySQL 8.x 默认时区是 UTC不加这个你往数据库里存的时间会跟本地时间相差 8 小时查出来的订单创建时间会整个乱掉。第二jackson.date-format决定了后端返回 JSON 时的时间格式如果不设置LocalDateTime默认序列化成一个数组而不是yyyy-MM-dd HH:mm:ss前端拿到后根本没法直接用。第三map-underscore-to-camel-case让数据库里的create_time自动映射到实体类的createTime避免每个字段都手动写TableField注解。4.2 登录认证JWT 的思路与拦截器的两个要点登录模块不建议做复杂的 Spring Security OAuth2 全家桶那是给自己找麻烦。常见做法是用 JWTJSON Web Token做令牌认证用户登录成功后后端把用户 ID 和角色信息加密进一个 token 返回给前端前端保存到本地之后的请求在 header 里带上这个 token后端用一个拦截器解析校验。这个方案代码量少讲解起来也清晰。生成 JWT 我一般用jjwt库配置一个密钥和过期时间就够了。核心逻辑如下public String generateToken(Integer userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) // 24小时过期 .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }这段代码里有几个参数说明。setSubject存用户 ID这是 token 的主体信息claim(role, role)把角色放进去后续管理员接口就可以直接在拦截器里判断角色而不用查数据库过期时间设 24 小时校园订餐场景里用户一天打开一次手机这个时间足够。SECRET_KEY不能写死成太短的字符串至少 32 位字符否则部分 JWT 库会因为密钥长度不够而报弱密钥异常。拦截器里要做两件事一是校验 token 是否存在二是校验是否过期。校验通过后把用户 ID 放到request.getAttribute(userId)这样 Controller 里直接取不必再走一遍 token 解析。有一个容易漏的点是放行白名单注册、登录、菜品列表这几个接口必须放行否则前端页面的首次加载都会因为没带 token 而 401。放行逻辑直接判断 URI 前缀即可不必引入 Shiro 或配置安全框架。4.3 下单接口的设计事务、状态校验与前端参数信任问题下单是业务核心也是最容易出现 bug 的地方所以代码逻辑要拆清楚。我习惯把“创建订单”和“支付”分开成两个接口而不是一个接口里直接一步到位。原因是待支付状态是一个真实存在的业务状态用户下单后可能去付款也可能取消把支付动作拆出来状态流转就顺了。创建订单的服务层代码如下Transactional(rollbackFor Exception.class) public OrderVO createOrder(Integer userId, ListOrderItemParam items, String remark) { // 1. 校验用户和菜品 if (items null || items.isEmpty()) { throw new BusinessException(订单项不能为空); } // 2. 查询菜品并计算总价, 同时做菜品状态校验 ListDish dishList dishMapper.selectBatchIds( items.stream().map(OrderItemParam::getDishId).collect(Collectors.toList()) ); // 3. 生成订单主记录, 状态为待支付 Orders order new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setStatus(0); // 4. 遍历明细, 组装 order_detail 数据 BigDecimal total BigDecimal.ZERO; ListOrderDetail details new ArrayList(); for (OrderItemParam param : items) { Dish dish dishMap.get(param.getDishId()); if (dish null || dish.getStatus() ! 1) { throw new BusinessException(菜品不存在或已下架: param.getDishId()); } OrderDetail detail new OrderDetail(); detail.setDishId(dish.getId()); detail.setDishName(dish.getName()); detail.setPrice(dish.getPrice()); detail.setQuantity(param.getQuantity()); details.add(detail); // 注意: 金额必须用数据库查出来的价格计算, 不能用前端传的 total total.add(dish.getPrice().multiply(new BigDecimal(param.getQuantity()))); } order.setTotalAmount(total); orderMapper.insert(order); // 5. 批量插入明细 details.forEach(detail - detail.setOrderId(order.getId())); orderDetailMapper.insertBatch(details); return convertToVO(order); }这段代码是完整可跑的骨架但有几个点比代码本身更重要。首先是Transactional(rollbackFor Exception.class)如果你不写rollbackForSpring 默认只在抛出RuntimeException时回滚而自己业务里抛的自定义BusinessException如果不继承RuntimeException事务是不会回滚的订单主表可能插入成功但明细插入失败产生脏数据。其次订单总价必须用后端从数据库查出来的菜品价格计算绝对不能信任前端传过来的price字段。攻击者完全可以在请求体里把价格改成 0.01然后你的系统就按 0.01 元出单了。这一点在答辩时主动提出来能体现你对接口安全的考虑。第三生成订单号的方法我单独抽了出来。简单做法是yyyyMMddHHmmss 用户ID 4位随机数这样有可读性也不会轻易重复。如果要更严谨可以用雪花算法需要引入 hutool 工具包一行代码调用IdUtil.getSnowflakeNextId()即可。4.4 两个 Controller 接口示例怎样把参数校验写清楚Controller 层的代码比较模板化这里我给两个有代表性的接口写法。一个是用户端的“提交订单”一个是管理端的“菜品上下架”。提交订单接口接收前端传来的菜品 ID 和数量列表然后调用上面那个 ServicePostMapping(/api/order/create) public ResultOrderVO createOrder(RequestBody Valid OrderCreateRequest request, RequestAttribute(userId) Integer userId) { OrderVO orderVO orderService.createOrder(userId, request.getItems(), request.getRemark()); return Result.success(orderVO); }参数说明RequestAttribute(userId)就是拦截器里塞进去的用户信息这样接口签名里就不用每次带 token 解析逻辑代码干净很多。用Valid触发参数校验比如items的 size 限制和quantity的最小值这些校验注解放在请求实体类的字段上。管理端上下架接口的区别在于要做一次角色判断。推荐方式是在拦截器里对/api/admin/**路径统一校验role是否为 1而不是在每个管理员接口里重复写 if。这样路径一设计好权限控制就自动覆盖了新加管理员接口时忘记加校验的概率也低很多。菜品上下架本身的实现就是根据 ID 更新status字段注意这里不能把status改成数据库里没定义的数字所以入参用枚举类型接收会比较安全。5. 避坑手册从环境搭建到并发场景的五个高频踩坑点做这类系统最花时间的其实不是写业务代码而是解决各种“莫名其妙就是不对”的问题。这一章我把最常见的几个坑按“现象、原因、解决”的方式整理出来顺序大致也对应项目开发的时间线从环境搭建到并发逻辑可以对照排查。5.1 时间字段全乱了数据库时间比当前时间早 8 小时现象接口返回的新增订单时间是2025-06-01 08:00:00而系统当前时间是2025-06-01 16:00:00所有时间都整体偏移了 8 小时。原因数据库连接串里没加serverTimezoneAsia/ShanghaiMySQL 8.x 默认使用 UTC 时区。另一个可能的原因是 JDBC 驱动版本和 MySQL 版本不匹配导致时区参数没生效。解决在 application.yml 的 datasource url 末尾加上serverTimezoneAsia/Shanghai。如果加了还不生效检查一下是不是拼写成了serverTimeZone这个参数大小写敏感。另外jackson.time-zone: GMT8也要确认它影响后端 JSON 序列化时的时区两个配置缺一不可。5.2orders表名导致 SQL 语句直接报错现象执行orderMapper.insert(order)时控制台报 SQL 语法错误但表结构明明没问题。原因order是 MySQL 的保留字排序用的ORDER BY就是它。虽然你的表名是orders而不是order但很多人设计订单表时确实会写成order表那 SQL 里会以保留字作为表名直接解析失败。即使你用了orders也要留心其他字段是否有类似问题比如from、desc。解决表名尽量避免保留字。如果已经建了order表有两个解决方向一是改表名二是给 SQL 加上反引号写成order。MyBatis Plus 里可以在实体类上用TableName(order)指定但我更建议直接建表时就用orders或t_order这种不会踩坑的名字。5.3 自定义异常导致事务没回滚生成了半截订单现象一个订单创建接口在明细插入时报了业务异常比如“菜品已下架”但接口返回错误后数据库里订单主表依然多了一条记录明细却是空的。原因Transactional默认只回滚RuntimeException和Error。自定义的BusinessException如果继承的是Exception业务方法抛出时 Spring 认为不是运行时异常事务就不会回滚。这也是我在代码里强调rollbackFor Exception.class的原因也是新人最容易忽略的地方。解决统一让业务异常继承RuntimeException或者在Transactional注解处显式写rollbackFor Exception.class。两个方案可以同时做互为保险。另外可以用 AOP 在应用层统一捕获异常并回滚但那是增加复杂度不推荐在新项目里直接用先把注解写对。5.4 本地前后端联调时请求被浏览器拦截现象前端页面在自己电脑上的一个端口运行比如 5173后端在 8080前端调用http://localhost:8080/api/order/create时提示“CORS error”或者“已被 CORS 策略阻止”。原因浏览器的同源策略限制了一个源的页面请求另一个源的接口即使都是本机端口不同也是跨域。Spring Boot 默认接口没有开启跨域支持所以浏览器把响应拦截了。解决在项目里加一个配置类实现WebMvcConfigurer重写addCorsMappings方法允许携带 token 和 content-type 头。还需要保证前端请求带上了Authorization头因为跨域时自定义头需要出现在allowedHeaders里否则即使跨域打开了token 还是传不过去。5.5 并发超卖同一道菜被同时下单库存数对不上现象一个库存只有 3 份的菜品两个用户同时下单各买 3 份都成功了菜品库存显示为负数或与实际不符。原因常见的“先查库存再判断是否购买成功然后扣减库存”的流程在并发时存在竞态条件。两个请求同时读到库存为 3都判断可以买然后各自扣减就出现了超卖。虽然低并发下很难出现但在答辩演示时如果刚好被问到程序就被动了。解决有库存表时把“查询并扣减”合并成一条 UPDATE 语句在 SQL 里用UPDATE dish SET stock stock - #{num} WHERE id #{id} AND stock #{num}这样扣减和判断是原子操作不会超卖。如果不想引入库存字段可以在订单创建时给某个订单加唯一约束或者暂时用乐观锁字段version控制效果差不多。6. 验收前做最后一步把流程自动化验证一遍再补一个定时关单系统写得差不多时不要急着写论文先用 Postman 或 Swagger 把订单完整流程跑通一遍确认每个状态跳转和权限拦截都符合预期然后再花半小时加上超时关单这个“小而亮”的功能。建议的验证顺序是注册一个新账号调用登录接口拿到 token带上 token 查询菜品列表创建订单此时订单状态为待支付调用模拟支付接口状态变为待取餐用管理员账号登录把状态改为制作中再改为已完成最后用同一个用户账号查询历史订单核对订单状态和时间。这个流程走完系统的核心闭环就没有大问题了。如果中间任何一步跳转不合法比如待支付直接改成已完成说明 Service 层缺状态校验需要去补。为什么要补超时关单因为实际场景里用户下单后可能忘记支付或直接离开这些“待支付”订单会一直堆在那里影响管理员对订单量的判断。最简单可靠的方案是 Spring Boot 自带的Scheduled定时任务每分钟扫描一次所有待支付且创建时间超过 15 分钟的订单将它们改为已取消。代码只有十几行Scheduled(cron 0 * * * * ?) public void autoCancelExpiredOrders() { LocalDateTime expireTime LocalDateTime.now().minusMinutes(15); orderMapper.updateStatusByTime(4, 0, expireTime); // 4已取消, 0待支付 }参数说明cron 0 * * * * ?表示每分钟执行一次也就是整分时跑一次不会对数据库造成压力。.minusMinutes(15)的 15 分钟是超时阈值可以根据食堂的实际出餐节奏调整比如中午高峰期可以缩到 10 分钟。updateStatusByTime是一个自定义 SQL形如UPDATE orders SET status 4 WHERE status 0 AND create_time #{expireTime}一次 UPDATE 批量生效不需要在 Java 里逐条判断。这个功能也能加进论文的“系统特色”章节简单但有现实意义。有一点要提一下Scheduled默认是单线程串行执行的如果同一个类里还有别的定时任务会在一个线程池里排队。影响不大但知道这一点可以防止以后扩展到多个定时任务时莫名其妙出现执行延迟。记得我刚做这类系统的时候最深的教训就是“不要急着写代码先把表和状态流转画清楚”。现在回头看很多返工都来自订单状态定义不统一前端写一个状态码后端写另一个状态码联调时还要反复对文档。后来我都把状态码的枚举定义放在一个公开文件里前后端共同引用这个问题就没有再出现过。希望这篇笔记能帮你把这个系统的路走顺一点少踩几个我已经踩过的坑。本文还有配套的精品资源点击获取