Spring Boot餐厅自助点餐结账系统:从数据建模到订单状态机实践
2026/9/13 8:10:24 网站建设 项目流程

简介:这是一套基于 Spring Boot 的餐厅自助点餐结账系统毕业设计项目源码,面向计算机、通信、人工智能、自动化等相关专业的学生、老师或从业者,适用于毕业设计、课程大作业及项目实战练习。项目覆盖点餐、结账等核心流程,代码经过调试,可直接运行,也便于在此基础上二次扩展。资源共 88 个文件,以 Java 源码、HTML/CSS/JS 前端页面、PNG/JPG 截图和 SQL 数据库脚本为主,并包含 XML/properties 配置及部署说明,压缩包约 1.26MB,结构清晰、上手门槛低。目前已有 99 人浏览学习,适合用来理解 Spring Boot 与数据库交互、页面联调及项目部署的完整思路。借助附带的数据库脚本与说明文档、部署命令说明,学习者能快速还原运行环境,并结合源码注释和截图逐模块拆解实现逻辑。作为评审得分为98分的个人毕设作品,开发思路和实现方式对准备答辩或做类似管理系统具有较强参考价值。

1. 餐厅自助点餐结账系统与 Spring Boot:从毕设题目到可运行 demo

餐厅自助点餐结账系统在 java 毕业设计里出现频率极高,核心原因并非它简单,而是它把用户扫码选餐、购物车、订单流转、结账支付和后台菜品上下架全部收进同一个业务闭环。用 Spring Boot 落地,正好能把分层架构、RESTful API 和数据库事务一次性展示出来。

对还没毕业的开发者,围绕“基于 springboot 的餐厅自助点餐结账系统源码+数据库”来做毕设,意味着需求分析、编码、测试和部署都要自己完成;对已经有几年工作经验的人来说,这个标题背后是一套可以复用的订单状态机与并发扣减思路。下面按数据建模、工程配置、接口实现、联调排错到答辩演示的顺序展开,给出的命令、代码和参数都可以直接照着改。

2. 数据模型与 Spring Boot 工程底座:建库脚本和依赖要一起定

2.1 最少需要五张表:实体划分与字段约束

自助点餐的数据链路很清晰:用户加入购物车,购物车提交成订单,订单拆成明细,支付后状态向下游流转。按照这个链路,最少五张表就能闭环:sys_user、dishes、cart、orders、order_item。

数据库课程设计里老师最爱追问的一个点,是 order_item 里为什么要冗余 dish_name 和 price,而不是只存 dish_id。答案很简单:菜品名称和价格会改,订单明细必须保留点餐那一刻的快照,否则三个月后的对账、退款、报表全部是错的数据。所以下面建表脚本里所有价格都用 DECIMAL(10,2),并且不建物理外键,表之间只通过 user_id、dish_id、order_id 做逻辑关联。

CREATE DATABASE IF NOT EXISTS restaurant DEFAULT CHARACTER SET utf8mb4; USE restaurant; CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, table_no VARCHAR(20) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE dishes ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, category VARCHAR(50) DEFAULT '热菜', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', stock INT DEFAULT 999 COMMENT '剩余份数,-1表示不限量', image_url VARCHAR(255) ); CREATE TABLE cart ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, table_no VARCHAR(20) DEFAULT '0', total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2制作中 3已完成 4已退款', pay_time DATETIME NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL );

这段脚本有几个约定可以直接照搬。金额用 DECIMAL(10,2),数据库里 99.90 能精确存放,float/double 在 sum 时容易出现 0.1+0.2 的尾差。dishes 表保留 status 字段而不是直接删除菜品,这样订单明细关联的菜品就算下架也不会查不到。orders 的 status 用数字常量而不是字符串,既省空间,又方便后续用 switch 做状态机。cart 表不冗余价格,因为价格随时可能变化,最终成交价以下单时计算为准。order_item 才是整个数据结构里最关键的一张表,所有结算、对账都以它的内容为准。

如果你喜欢用 Spring Data JPA 的自动建表,可以把上面的 SQL 收进 resources/db/init.sql,后面用脚本统一初始化。这样数据库课程设计要求的“建表脚本”和“初始化数据”都留了痕迹,答辩时也拿得出手。

2.2 Spring Boot 依赖怎么组合:JPA 还是 MyBatis

工程依赖我一般只保留最小集合:web、数据持久化、MySQL 驱动和 Lombok。下面的 pom 片段足够启动一个带数据库访问的 Spring Boot 项目:

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

如果你平时更习惯 MyBatis,可以把 spring-boot-starter-data-jpa 换成 mybatis-spring-boot-starter,其余结构不变。这里用 JPA 是为了把建表、插入和 update 都交给 ORM 处理,毕设代码量更少。要提醒的是,如果 IDEA 创建项目时选的 Spring Boot 3.x,包名已经从 javax.* 变成 jakarta.*,老的 MyBatis 依赖如果还停在 2.x,编译会直接报 ClassNotFoundException。两种持久化方案的取舍用下面这张表说明:

对比项Spring Data JPAMyBatis / MyBatis-Plus
建表方式实体映射 + ddl-auto手写 SQL
适合场景表结构稳定、CRUD 多复杂查询、动态 SQL
毕设工作量
常见坑N+1 查询XML 路径写错

从毕设完成速度看,直接用 JPA 能省掉大量 XML;如果你的报告里写的是 MyBatis,就把依赖和 Mapper 接口换回去,Controller 和 Service 的分层保持不变。

2.3 application.yml 里的连接参数:把时区和建表策略一次配好

数据库连通性的坑几乎都集中在连接串参数上。下面是能直接跑的本地点餐系统配置:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/restaurant?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true open-in-view: false

url 里的 serverTimezone 写成 Asia/Shanghai,是因为 MySQL 8 默认时区很多时候不是中国时区,不设会在连接时报 The server time zone value 错误。useSSL=false 只是本地开发省去证书环节,线上不要照抄。allowPublicKeyRetrieval=true 是 MySQL 8 在非 SSL 连接下必须加的,否则会抛出 Public Key Retrieval is not allowed。ddl-auto 设为 update 时,JPA 会自动把实体映射成表结构;既然已经给了建表脚本,更推荐改成 none,避免实体字段和表字段不一致时被自动改动。open-in-view 一定要关,否则一次请求会长时间占用数据库连接。

写到这里,项目的包结构也固定下来,Controller 只接收参数并返回 JSON,Service 负责事务和业务规则,Repository 负责 SQL。下面开始进入接口实现。

3. 点餐与结账的接口实现:状态流转、并发扣减与幂等设计

3.1 加购接口:Controller 只做参数转发,事务交给 Service

先看加购接口。前端扫码后拿到 userId、dishId 和 quantity,Controller 保持轻薄,只做参数接收:

@RestController @RequestMapping("/api/cart") @RequiredArgsConstructor public class CartController { private final CartService cartService; @PostMapping("/add") public Result add(@RequestParam Long userId, @RequestParam Long dishId, @RequestParam(defaultValue = "1") Integer quantity) { cartService.add(userId, dishId, quantity); return Result.ok(); } }

@RequiredArgsConstructor 是 Lombok 的构造器注入,配合 final 字段,能在编译期避免空指针。添加购物车不是把一条数据插进 cart 表就结束;推荐用更新数量而不是重复插入,Service 实现如下:

@Service @RequiredArgsConstructor public class CartService { private final CartRepository cartRepository; private final DishRepository dishRepository; @Transactional public void add(Long userId, Long dishId, Integer quantity) { Dishes dish = dishRepository.findById(dishId) .orElseThrow(() -> new BusinessException("菜品不存在")); if (dish.getStatus() == 0) { throw new BusinessException("菜品已下架"); } Cart cart = cartRepository.findByUserIdAndDishId(userId, dishId); if (cart == null) { cart = new Cart(); cart.setUserId(userId); cart.setDishId(dishId); cart.setQuantity(quantity); } else { cart.setQuantity(cart.getQuantity() + quantity); } cartRepository.save(cart); } }

这里把 @Transactional 放在 Service 方法上,而不是 Controller,这样查询菜品和保存购物车在同一事务里,任何一个环节抛异常都会回滚。CartRepository 的 findByUserIdAndDishId 是 Spring Data JPA 根据方法名自动生成的查询,不需要手写 SQL。如果遇到 JPA 方法解析失败,检查实体里有没有对应名称的字段,或者改成 @Query 显式声明。

3.2 生成订单:先落订单头,再落订单明细

下单的逻辑是把当前用户的购物车批量转成订单明细,并且生成订单头。这里有一步很容易被忽略:OrderItem 需要 orderId,所以不能用内存对象直接关联,必须先保存 Order。

@Transactional public Order submit(Long userId) { List<Cart> cartList = cartRepository.findByUserId(userId); if (cartList.isEmpty()) { throw new BusinessException("购物车为空"); } Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setStatus(0); order.setTotalAmount(BigDecimal.ZERO); orderRepository.save(order); BigDecimal total = BigDecimal.ZERO; for (Cart cart : cartList) { Dishes dish = dishRepository.findById(cart.getDishId()) .orElseThrow(() -> new BusinessException("菜品已下架")); OrderItem item = new OrderItem(); item.setOrderId(order.getId()); item.setDishId(dish.getId()); item.setDishName(dish.getName()); item.setPrice(dish.getPrice()); item.setQuantity(cart.getQuantity()); orderItemRepository.save(item); total = total.add(dish.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity()))); } order.setTotalAmount(total); orderRepository.save(order); cartRepository.deleteByUserId(userId); return order; }

generateOrderNo() 用本地时间加随机数实现,保证演示环境里不重复:

private String generateOrderNo() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + String.format("%04d", ThreadLocalRandom.current().nextInt(10000)); }

order_no 不是数据库自增主键,而是业务编号,避免把订单号直接暴露给前端。生成规则被面试官问到时,补一句“生产环境换成雪花算法”就能把深度拉开。cartRepository.deleteByUserId 清理购物车必须在事务方法内完成,一旦后面的支付失败,购物车删除也会一并回滚。

3.3 并发扣库存:用一条 update 而不是先查后改

自助点餐最容易被问并发的地方是菜品库存。3.2 的 submit 方法里每读到一个购物车菜品,就应该先扣一次库存,再生成明细;下面把扣减逻辑单独拆出来讲。常见错误写法是先查询剩余库存,再判断是否够扣,最后 update。两个请求同时读到 stock=1,各自扣 1,最终变成 -1,这就是超卖。正确做法是把判断和扣减放进同一条 SQL:

@Modifying(clearAutomatically = true, flushAutomatically = true) @Query("update Dishes d set d.stock = d.stock - :quantity " + "where d.id = :dishId and d.stock >= :quantity") int deductStock(@Param("dishId") Long dishId, @Param("quantity") Integer quantity);

调用时:

if (dishRepository.deductStock(dish.getId(), cart.getQuantity()) == 0) { throw new BusinessException("库存不足"); }

这条 update 在数据库层面完成“检查库存 + 扣减”两步,返回 0 表示条件不满足。多线程并发下,同一行会被行锁串行化,不会超卖。@Modifying 里的 clearAutomatically 让 JPA 在执行完更新后清空一级缓存,flushAutomatically 保证当前事务未刷新的实体先落到数据库,两个参数在并发场景下尽量都开着。如果你的菜单里存在“不限量”的菜品,记得在 Service 里加判断,不要对 stock 为 -1 的菜执行这条 SQL。

3.4 结账接口:状态位和幂等是同一件事

结账接口负责把订单状态从待支付改成已支付,并记录支付时间。最基本实现:

@Transactional public PayResult pay(String orderNo) { Order order = orderRepository.findByOrderNo(orderNo) .orElseThrow(() -> new BusinessException("订单不存在")); if (order.getStatus() != 0) { throw new BusinessException("当前订单状态不支持支付"); } order.setStatus(1); order.setPayTime(LocalDateTime.now()); orderRepository.save(order); return new PayResult(orderNo, order.getTotalAmount()); }

如果两个请求同时来到 pay,JPA 的 save 可能执行两次 update,第二次会把状态从 1 改成 1,结果看起来正常,但已经多处理了一次支付回调。更稳的做法是再加一条条件更新:UPDATE orders SET status = 1 WHERE order_no = ? AND status = 0,返回值是 0 就直接判定为重复支付。订单状态机建议用下面的常量表管理,不要散落魔法数字。

status含义允许的操作
0待支付支付、取消
1已支付通知后厨、制作
2制作中标记完成
3已完成可退款
4已退款

Java 里可以用 enum 或常量类管理这些状态,数据库里保留 TINYINT,避免字符串索引膨胀。结账对账时,只看订单头和订单明细两张表就够了,这就是前面设计 order_item 快照的回报。

4. 初始化数据库、本地联调与高频报错排查

4.1 测试数据:让演示环境一开始就有菜单和用户

系统跑起来之前,先把测试数据准备好,否则前端页面一片空白。下面的 SQL 可以直接放到 src/main/resources/db/ 下:

INSERT INTO sys_user (id, username, password, table_no) VALUES (1, '13800138000', '123456', 'A12'); INSERT INTO dishes (name, price, category, status, stock) VALUES ('宫保鸡丁', 36.00, '热菜', 1, 100), ('干煸四季豆', 26.00, '热菜', 1, 100), ('酸辣土豆丝', 18.00, '热菜', 1, 100), ('米饭', 3.00, '主食', 1, 999), ('可乐', 8.00, '饮品', 1, 999);

菜品状态 status=1 表示上架,前端菜单接口只查 status=1。库存 999 表示演示环境基本够用,但代码里如果执行 deductStock,999 会被减成 998,不影响展示;如果要严格表达“不限量”,要么给 dishes 单独加 unlimited 字段,要么在扣库存逻辑里跳过 stock = -1 的菜品。测试数据不要放在 application.yml 旁边,统一放进 resources/db/ 下,方便答辩时用一条命令重建。

4.2 Linux/macOS 下从零启动的最小命令序列

本地联调最省事的路径是 MySQL 直接装在系统上,然后依次执行下面几步:

# 启动 MySQL(macOS 用 mysql.server,Linux 用 systemctl) mysql.server start # 创建数据库,指定 utf8mb4 避免中文乱码 mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS restaurant DEFAULT CHARACTER SET utf8mb4" # 导入建表和初始数据脚本 mysql -uroot -p restaurant < src/main/resources/db/init.sql # 启动 Spring Boot 项目 cd restaurant mvn spring-boot:run

mysql.server start 只对本地安装的 MySQL 有效,如果用 Docker 跑数据库,换成对应的 docker start 命令。CREATE DATABASE 必须显式指定 utf8mb4,否则默认字符集可能是 latin1,中文会变成问号。init.sql 里包含了建表语句和测试数据,导入时一次执行完。mvn spring-boot:run 默认读取 src/main/resources/application.yml,启动成功的关键看日志里是否出现 Tomcat started on port 8080。如果你的 mvn 命令不在环境变量里,先用 IDEA 的 Maven 面板执行 spring-boot:run。

4.3 curl 自测:加购、下单、支付一条龙

应用启动后,不需要前端页面也能验证接口。先用 curl 按顺序打一遍:

# 1. 查看菜单 curl "http://localhost:8080/api/dishes/list" # 2. 用户 1 加购 2 份宫保鸡丁(dishId=1) curl -X POST "http://localhost:8080/api/cart/add?userId=1&dishId=1&quantity=2" # 3. 下单,返回体里带 orderNo curl -X POST "http://localhost:8080/api/order/submit?userId=1" # 4. 支付,把 orderNo 换成上一步返回的单号 curl -X POST "http://localhost:8080/api/order/pay?orderNo=20250115120000123"

第四步的 orderNo 每次生成都不一样,手工复制容易出错。更快的做法是用 python3 解析 JSON,自动取 orderNo:

ORDER_NO=$(curl -s -X POST "http://localhost:8080/api/order/submit?userId=1" | python3 -c "import sys, json; print(json.load(sys.stdin)['data']['orderNo'])") curl -s -X POST "http://localhost:8080/api/order/pay?orderNo=$ORDER_NO"

这样连续点击也不会出现重复下单的问题。支付成功后回数据库确认:

SELECT o.order_no, o.status, o.total_amount, GROUP_CONCAT(i.dish_name) FROM orders o LEFT JOIN order_item i ON o.id = i.order_id GROUP BY o.id;

能看到 status 变成 1,就说明加购、下单、结账这条主链路是通的。如果 status 还是 0,先看接口返回的错误信息,再回头查代码里状态判断的条件。

4.4 Spring Boot 3.x 与旧代码的兼容问题:版本太高怎么办

IDEA 创建 springboot 项目时默认版本更新很快,很多校园项目模板还停留在 2.x,直接打开 3.x 项目会碰到一串类找不到。下面这张表是点餐系统联调里最常见的报错:

报错原因处理
The server time zone value 'xxx' is unrecognizedMySQL 时区未配置JDBC URL 增加 serverTimezone=Asia/Shanghai
Access denied for user 'root'@'localhost'密码错误或权限不足检查 application.yml 的 password,或执行 ALTER USER
Port 8080 was already in use端口被占用lsof -i:8080 查看 PID,kill 或改 server.port
Failed to configure a DataSourcedatasource 配置不存在看 yml 的缩进和 spring.datasource 前缀
ClassNotFoundException: javax.servlet.ServletSpring Boot 3.x 引入了 javax 系依赖降级到 Spring Boot 2.7.x,或把 javax.* 换成 jakarta.*

Spring Boot 从 3.0 开始正式切换到 jakarta 命名空间,javax.servlet 的旧依赖在编译期就会被卡住。我的处理原则是:如果手头源码包是按照 Spring Boot 2.x 写的,不要硬升,直接在 pom 里锁定 2.7.x;如果坚持用 3.x,就全局搜索替换 javax.* 为 jakarta.*,同时把 MyBatis 等相关依赖升级到适配 3.x 的版本。还有一个小坑是 Lombok 版本过低,在 JDK 17 下会报错误,把 Lombok 升到较新版本即可。

5. 让毕设从“能跑”到“能讲”:演示脚本与三个追问点

5.1 五分钟重置一遍的演示脚本

答辩演示最怕数据被上一次点餐弄乱,所以要把重置命令整理成一个脚本,上台前跑一次:

# 1. 重置业务数据,保留表结构 mysql -uroot -p restaurant -e "DELETE FROM order_item; DELETE FROM orders; DELETE FROM cart;" # 2. 重新导入演示菜单 mysql -uroot -p restaurant < src/main/resources/db/demo-data.sql # 3. 启动项目(如果已在运行可跳过) mvn spring-boot:run # 4. 加购并下单 curl -s -X POST "http://localhost:8080/api/cart/add?userId=1&dishId=1&quantity=2" RESP=$(curl -s -X POST "http://localhost:8080/api/order/submit?userId=1") # 5. 自动解析 orderNo 并支付 ORDER_NO=$(echo "$RESP" | python3 -c "import sys, json; print(json.load(sys.stdin)['data']['orderNo'])") curl -s -X POST "http://localhost:8080/api/order/pay?orderNo=$ORDER_NO"

DELETE 执行顺序必须先删 order_item,再删 orders,最后删 cart,否则订单关联的明细数据会导致外键或残留问题。演示前不需要重建表,因为表结构已经固定在 init.sql 里。脚本里把 echo "$RESP" 打印出来能帮助快速定位接口问题。

5.2 答辩里最容易被追问的三个实现细节

第一个是订单为什么拆成 orders 和 order_item 两张表。要回答:一张表只能对应一个订单总额,无法表达一单多菜,也无法按菜品维度做后厨分单;拆开后订单明细表存菜品快照,才能支持退款部分菜品和报表统计。

第二个是库存扣减怎么防超卖。要回答:依赖数据库行锁,UPDATE 语句里同时判断stock >= quantity,返回影响行数为 0 就说明库存不足,不提前做 SELECT 再 UPDATE。

第三个是支付接口幂等怎么实现。要回答:支付前检查订单状态,并且用WHERE status = 0的条件更新挡住重复请求;真正对接微信或支付宝时,还要用支付平台交易号做去重表。

这三个点刚好对应点餐系统的表设计、并发控制、接口安全,是 java 面试题里常被挑出来问的深度点,也是毕业设计答辩的高频问题。

5.3 上台前最后检查 4 个点

  • 数据库服务已经启动,mysql -uroot -p能登录。
  • 8080 端口没有被占用,lsof -i:8080无输出。
  • 菜单数据存在,SELECT COUNT(*) FROM dishes WHERE status=1大于 0。
  • 用户表有测试账号,username 和密码能在登录接口通过。

然后完整跑一遍上面的脚本,看到数据库里 status 从 0 变成 1,这套基于 Spring Boot 的餐厅自助点餐结账系统就算真正闭环了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询