ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot预定点餐系统:核心业务与并发控制实践

Spring Boot预定点餐系统:核心业务与并发控制实践

1. 项目概述与选题价值

1.1 这个系统到底解决什么问题

预定点餐系统,说白了就是让用户提前下单、约定时间取餐或者就餐的一套 Web 应用。很多做毕业设计的同学一听到“点餐系统”就觉得烂大街了,确实,如果只是做一个普通的点餐 CRUD,那没什么含金量。但“预定”这两个字才是这套系统的灵魂,它牵扯到时间段的库存管理、订单状态流转、超时处理,甚至还有商家营业时间的动态配置。这些点加起来,工作量和技术含量一下子就上来了。

我见过太多人选题的时候眼高手低,一上来就分布式、微服务,结果连 Spring Boot 基础都写不利索。预定点餐系统这个选题好在哪里?它足够真实——你去任何一家餐厅,尤其是那种需要等位的热门店,预约点餐都是刚需;它的业务链条完整——从用户选菜、提交预定、商家接单、出餐、到用户取餐,每个环节都有清晰的业务动作;它的技术栈适中——Spring Boot + MyBatis-Plus + MySQL 这套组合就能跑得很顺,做毕业设计绰绰有余。

1.2 适合什么基础的人来做

如果你已经能独立写完一个 Spring Boot 的增删改查,这个项目大概两到三周就能做出一个像样的版本。如果你刚学完 JavaWeb 不久,Spring Boot 只是知道个皮毛,那也没关系,因为这个项目真正难的地方不在框架本身,而在业务逻辑的梳理。我下面写的内容会按照一个完整的开发流程来走,从需求分析到表结构设计,再到核心代码实现和最后的避坑指南,你跟着一步步来就行。

如果你是导师要求你必须做一个“有点业务深度”的系统,那这个选题也很合适。表面上它是一个点餐系统,但你可以在答辩的时候把“预定时间段控制”“库存扣减的并发处理”“订单状态的有限状态机跳转”这些点讲清楚,导师一听就知道你不是在堆 CRUD。

2. 整体设计思路:为什么推荐这套方案

2.1 技术选型的逻辑和取舍

先聊聊技术栈。Spring Boot 作为主框架基本没什么争议,它是目前 Java 领域做 Web 应用最主流的方案,社区活跃、资料多、遇到问题搜得到。持久层我强烈建议用 MyBatis-Plus 而不是 JPA,原因很简单:毕业设计里的很多查询都是多表关联和动态条件,MyBatis-Plus 的 LambdaQueryWrapper 写起来直观,SQL 又能自己控制,调试起来比 JPA 的自动 SQL 好排查得多。权限认证用 Sa-Token 或者 JWT 都行,我更倾向于 JWT + 拦截器这套,够简单也够用,不用引入太重的安全框架把项目搞复杂。

前端的话,如果你不是专门做前端的,别折腾 Vue + Element UI 那套了,直接用 Thymeleaf 服务端渲染或者 Bootstrap 写页面就行。答辩的时候老师看的是整个系统的完整度和业务闭环,不是你的页面动画有多炫。

数据库用 MySQL,缓存用 Redis,但注意,Redis 不是必须的。如果你的系统只是一个演示级别的项目,Redis 可以在答辩的时候作为一个加分项提出来,比如用来存验证码、做登录状态缓存,但别硬上,否则徒增复杂度。

2.2 系统角色与业务流程梳理

预定点餐系统的角色划分,我建议做三类:用户、商家、管理员。用户端负责注册登录、浏览菜品、加购、提交预定订单、查看订单状态、取消预定;商家端负责菜品管理、订单接单/拒单、出餐标记、查看预定统计;管理员负责用户管理、商家审核、整体数据看板。

核心业务流程是这样的:

  1. 用户选择就餐时间段(比如中午 11:30 到 12:00 这个区间),系统检查该时间段是否可预定。
  2. 用户选定菜品加入购物车,提交订单时锁定库存,生成“待支付”或“待确认”状态的订单。
  3. 商家在商家端看到新订单,可以选择接单或拒单。接单后系统自动把该时间段的剩余库存减掉。
  4. 商家出餐后标记“出餐完成”,用户端收到状态变更。
  5. 用户到店取餐,订单状态终态为“已完成”。

这里面最关键的业务规则就是时间段的库存控制。举个例子,某个菜品在 11:30-12:00 这个时间段限量供应 20 份,用户预定的时候系统要先查这个时间段已经预定了多少份,如果满了就提示不可预定。这个逻辑写不好就会出并发问题——两个人同时抢最后一份,结果两个人都下单成功了。

2.3 模块划分与工程结构

如果说业务逻辑是系统的灵魂,那工程结构就是系统的骨架。很多人一上来就把所有代码塞到 controller 和 service 里,最后自己都看不懂。我建议用经典的分层结构:

com.example.order ├── controller // 控制层,只做参数接收和结果封装 ├── service // 业务逻辑层,核心规则都在这里 │ └── impl ├── mapper // MyBatis-Plus 的 Mapper 接口 ├── entity // 数据库实体类 ├── dto // 前端传参对象 ├── vo // 返回给前端的数据对象 ├── config // 配置类:跨域、拦截器、Redis等 ├── common // 通用工具类、统一返回结果、异常处理 └── utils // 工具类:JWT工具、日期处理等

每一项都有它的作用。dto 和 vo 很多人会忽略,但这两个东西在接口设计里特别重要。dto 负责“接收什么”,vo 负责“返回什么”,把它们和 entity 分开,就不会出现某个字段不想暴露给前端却在返回值里出现的情况。

3. 数据库设计:把地基打好比什么都强

3.1 核心表结构和字段说明

数据库设计是毕业设计的重头戏,也是答辩时容易出彩的地方。我给出一套适合预定点餐系统的表结构方案:

用户表(user):存用户的基本信息,包括用户名、密码(加密存储)、手机号、角色标识。角色我建议直接用一个整数字段区分,1 是用户,2 是商家,3 是管理员,不用单独建角色表。

菜品表(dish):菜品名称、图片、价格、描述、所属商家、库存量、上架状态。

预定时间段表(time_slot):这是核心表之一。字段包括开始时间、结束时间、时段名称(午餐/晚餐)、可预定的总份数、已预定的份数、状态。为什么单独建一张表?因为时间段是商家可以自己配置的,今天营业到晚上八点,明天可能到九点,这些都应该允许商家动态维护。

订单表(orders):订单编号、用户 id、商家 id、菜品快照、总金额、下单时间、预定时间段、取餐时间、状态。注意我把“菜品快照”单独拿出来,意思是下单时把菜品的名称、价格、图片等信息直接以 JSON 形式存进去,这样商家之后改菜品价格也不影响历史订单。

订单明细表(order_detail):订单 id、菜品 id、菜品名称、数量、单价、小计。这张表和订单表是 1 对 N 的关系。

3.2 为什么订单要存菜品快照

很多新手会犯一个错误:订单明细只存菜品 id,查询的时候再去联表查 dish 表拿名称和价格。这么做的后果是,如果商家修改了菜品价格,你的历史订单金额对不上了。正确做法是在下单那一刻,把菜品信息复制一份存进订单明细表。这个设计思路叫“快照模式”,不只是点餐系统,电商的订单就是这么做的。你在答辩的时候能把这个细节讲出来,导师会认为你确实思考过业务,而不是只会照着视频敲代码。

3.3 预定扣库存的数据库方案

关于预定的库存控制,最简单的方案是在 time_slot 表上加一个已预定数字段,下单时执行:

UPDATE time_slot SET reserved_count = reserved_count + 1 WHERE id = #{slotId} AND reserved_count < total_count;

这条 SQL 通过条件reserved_count < total_count来保证不会超卖。数据库的行锁会保证这条更新在同一时刻只有一个事务能成功,所以天然是并安全的。但要注意,如果你们用的是 MyBatis-Plus 的 updateById,它是不会带这个条件判断的,必须写自定义 SQL 或者使用 Wrapper 的条件构造。

这套方案的好处是简单直观,缺点是高并发场景下会有性能瓶颈,但对于毕业设计的规模完全够用。你要在答辩中说清楚这个方案的原理,就已经比 90% 的人强了。

4. 核心代码实现:把设计落成实际可用

4.1 Spring Boot 项目的基础搭建

项目创建我推荐直接用 IDEA 的 Spring Initializr 生成,Java 8 或者 11 都行,Spring Boot 版本用 2.7.x 就好,稳定且大多数教程都可以参考。依赖方面需要 spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java、lombok、spring-boot-starter-validation 这几个基本就够。

application.yml的核心配置如下:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/reserve_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

有一个小坑必须提醒:连接数据库时一定要加serverTimezone=Asia/Shanghai。很多人测试环境没问题,部署到服务器时间就差了 8 个小时,基本都是因为这个参数没配。

4.2 预定点餐的核心业务逻辑实现

订单提交是整个系统最核心的接口,我直接给出一个简化的实现思路:

@Transactional public OrderVO createOrder(OrderCreateDTO dto) { // 1. 校验时间段是否还在可预定范围 TimeSlot slot = timeSlotMapper.selectById(dto.getSlotId()); if (slot == null || slot.getStatus() == 0) { throw new BizException("该时间段已停止预定"); } // 2. 校验库存并预扣 TimeSlot slotCondition = new TimeSlot(); slotCondition.setId(slot.getId()); int updated = timeSlotMapper.updateReservedCount(slot.getId(), slot.getTotalCount()); if (updated == 0) { throw new BizException("该时间段预定已满,请更换时间"); } // 3. 生成订单主记录 Order order = new Order(); order.setOrderNo(generateOrderNo()); // ... 设置用户、金额、状态等 // 4. 保存订单明细及菜品快照 List<OrderDetail> details = buildOrderDetails(dto.getItems()); orderDetailMapper.insertBatch(details); // 5. 返回订单信息 return orderToVO(order); }

@Transactional一定要加,因为库存扣减和订单生成必须是一个原子操作,中间任何一步失败都要回滚,不然会出现库存扣了订单没建或者反过来。

有一个非常容易踩的坑是锁库存的时机。我见过很多实现是在用户提交订单的时候就把库存扣了,等支付的时候再检查发现没支付就取消订单。正确做法是分两步:

  • 提交订单时“预占”库存,生成待支付订单。
  • 用户限时支付(比如 15 分钟),支付成功后把预占变成实际占用。
  • 超时未支付,定时任务或者用户主动取消时释放库存。

这样做的好处是不会出现“货被占着但没人买”的情况。

4.3 订单状态机设计

订单状态我建议用一个枚举类来管,不要用散落的字符串:

public enum OrderStatus { PENDING_PAYMENT(0, "待支付"), PENDING_CONFIRM(1, "待商家确认"), CONFIRMED(2, "已接单,制作中"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"), REJECTED(5, "商家拒单"); private final int code; private final String desc; // 构造器和 getter... }

状态机的价值在于让状态的迁移变得可控制和可审计。比如,只有处于 PENDING_CONFIRM 状态的订单才能被商家接单;只有 CONFIRMED 状态才能完成出餐;只有 PENDING_PAYMENT 和 PENDING_CONFIRM 状态的订单能被取消。这些规则如果散落在业务代码里,久了肯定会乱,集中到状态机里后每次状态变更只需要调用一个统一的方法。

4.4 接口设计与前端联调

后端接口的返回格式一定要统一。我习惯用这样一个结构体:

{ "code": 200, "message": "操作成功", "data": { ... } }

对应的 Java 类就是Result<T>,加上@RestControllerAdvice全局异常处理器,业务异常返回标准错误码,参数校验异常也统一拦截。这样前端处理逻辑会非常清爽,不会出现“有时候返回数组有时候返回对象”这种毁体验的情况。

接口文档我用 Apifox 或者 Postman 联调。如果你没有太多前端基础,直接用 Swagger 自动生成接口文档,前端照着文档调就行。

5. 常见问题与排查技巧实录

5.1 时间字段的时区错乱问题

这个是老生常谈,但每年都有无数人踩。症状是数据库里存的时间是对的,查出来显示的时间差了 8 小时。解决方法就两步:

  1. 数据库连接 URL 上加serverTimezone=Asia/Shanghai。
  2. 实体类的时间字段用LocalDateTime而不是Date,并且加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")。

如果用了 Fastjson 序列化,要额外设置SerializeConfig的日期格式,不然也可能乱。反正我的原则是,能用LocalDateTime就不用Date,能显式指定时区就绝不依赖默认值。

5.2 并发场景下库存超卖

做演示的时候,如果导师现场点了一个预定,页面卡了一下多提交了几次,系统就有可能出现超卖。原因大概率是你的库存扣减逻辑用的是“先查再更新”:

TimeSlot slot = timeSlotMapper.selectById(id); if (slot.getReservedCount() < slot.getTotalCount()) { slot.setReservedCount(slot.getReservedCount() + 1); timeSlotMapper.updateById(slot); }

这种写法在并发下必出问题。两个线程同时读到 reserved_count = 19,都判断可以继续加,结果两个都更新成了 20,超卖一份。正确写法只有一条:就是上面我给出的那个带条件更新的 SQL。如果你不想写 XML,MyBatis-Plus 的 UpdateWrapper 也能做:

timeSlotMapper.update(null, new LambdaUpdateWrapper<TimeSlot>() .eq(TimeSlot::getId, slotId) .lt(TimeSlot::getReservedCount, TimeSlot::getTotalCount) .setSql("reserved_count = reserved_count + 1"));

注意这里的setSql是让数据库去计算新的字段值,而不是从 Java 里传一个可能过期的值。

5.3 跨域问题导致前后端联调失败

典型场景:前端跑在 8081 端口,后端跑在 8080 端口,浏览器直接拦截跨域请求。我的习惯是后端写一个CorsFilter或者配置类全局放开。开发环境放开所有来源,生产环境再收敛。

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

如果你用了拦截器做 JWT 验证,记住多一步处理:预请求 OPTIONS 请求也要放行,不然前端发现所有请求都在 CORS 那里挂掉了。

5.4 远程调试时常见的环境问题

项目做完以后难免要部署给老师看,或者你找人帮忙远程调试,最常见的坑是这些东西:

  • MySQL 版本不一致导致驱动报错,比如 MySQL 8 的驱动类名变了,要用com.mysql.cj.jdbc.Driver。
  • 端口被占用,改一下server.port就好,一般在部署机器上很难排除这个,因为本机跑着多个 Java 项目是常态。
  • 数据库初始化脚本没有手动执行,或者字符集没设定好,导致插入中文变成问号。解决办法是建库的时候指定CHARACTER SET utf8mb4。

有一个小技巧,部署前把建库脚本dump出来,放到项目的sql目录下,同时写一个部署说明.md,里面写清楚环境要求、初始化步骤、如何启动。这不仅是对你自己负责,答辩的时候老师看到你连部署文档都有,印象分直接拉高。

5.5 答辩演示时的避坑技巧

答辩演示是最容易翻车的环节,我总结几个亲测有效的做法:

现场演示不要用本地数据库连他自己的环境,用你自己云上的公共环境,这样即使他电脑出现奇怪问题也不怕。

准备几条测试数据,比如菜品、时间段,预先设定好。现场直接在 JUnit 测试或者接口文档里操作,不要现场一步步创建。

下单流程演示时,最好先展示“库存充足时能下单成功”,然后把某个时间段改成“已满”状态再去下单验证失败场景。这样能给导师展示你确实考虑了边界情况。

被问到“这个系统和高并发真实的预定系统有什么区别”的时候,不需要慌。诚实说数据库行锁方案适合中小规模,大规模可以换成 Redis 预减库存 + MQ 异步订单处理的方案,说明你有知识面的延伸就够了。

6. 我的实操心得与建议

6.1 开发节奏怎么安排

做毕业设计最怕的是拖到最后一个月硬赶。按照我的经验,合理的排期应该是:

第 1 周:需求分析和数据库设计。这一周别急着写代码,先用文档把你系统的业务规则写清楚。哪些角色、哪些状态、每个状态的跳转条件是什么。数据库字段想清楚了再动手建表,建表改来改去是最浪费时间的。

第 2 周:搭建项目框架,完成用户注册登录、菜品展示、预定时间段管理的基础功能。这一周应该是你敲代码速度最快的时候,把框架跑通、页面能跳转、数据能流通,你就成功了大半。

第 3 周:实现订单核心流程。提交订单、库存扣减、商家接单、状态流转。这是整个项目最难的部分,也是最值得花时间的部分。建议把这一块的代码反复走读几遍,做到有能力现场讲解。

第 4 周:完善细节,写文档,准备答辩。文档不仅仅是毕业论文,还包括项目部署说明、接口文档。把系统部署到云服务器上,确保导师现场能访问。

6.2 关于“源码+文档+远程调试”这种服务模式的思考

标题里提到的“全bao定制”,其实是现在技术服务的常见模式。我的看法是,作为学习者,你可以买成品参考,但一定要自己走一遍代码、改逻辑、加功能,否则答辩的时候一问三不知,等于给自己挖坑。特别建议在拿到项目后做两件事:

第一,把项目里的包名、类名、数据库注释全部按你的需求重新翻一遍。你改过名字的类、写过注释的地方,系统的印象会深很多。

第二,在核心模块比如订单创建的 Service 里加几行关键注释,甚至可以加一个自定义的校验规则(比如预定人数不能超过某个值)。这种“二次创作”会让系统看起来更像你自己的东西。

我见过不少同学,答辩时被导师问“这个字段为什么这么设计”“这个异常什么时候会抛”,直接愣了。其实这些问题都没有标准答案,只要你当时写的时候确实想过,哪怕说得不完美,导师也觉得你是自己做的。而只要是照着项目念,连项目结构都解释不清,那基本等于放弃抵抗了。

6.3 做这个项目我学到最重要的东西

技术层面的东西绕不开 Spring Boot 那套,但真正让我觉得做成这件事的是“状态机的思维”。订单不是一锤子买卖,从下单到完成中间有无数种可能的状态变化,把这些状态和触发条件理清楚了,代码写起来条理清晰,调试起来也舒服。这种思维方式放到其他任何业务系统里,都非常受用。

等你答辩完回顾这段经历,会很清楚地感受到一个完整的项目是怎么从零到一搭起来的。这中间踩过的坑、重构过的代码、为了一个 bug 熬夜翻日志的瞬间,都是这个选题真正回馈给你的东西。希望这篇分享能让你少走一些弯路,把时间花在真正值得打磨的业务逻辑上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进