
简介这是一套基于Spring Boot MyBatis-Plus Layui实现的餐厅网络点餐系统课程设计源码配有数据库文件适合JavaWeb方向的学生完成课设或毕业设计时参考。系统围绕企业内部餐厅的点餐场景实现了食谱管理、每周菜单生成、员工限时下单、9点后厨房统单打印等核心流程涉及文件上传、静态资源映射、订单汇总等典型开发细节。资源包为Zip格式总计913个文件约6.49MB其中包含460个图片资源菜品图与界面切图、171个JavaScript与98个CSS用于Layui及前端交互、62个Java源码、33个HTML页面、20个Map映射文件以及SQL数据库脚本和项目配置文档结构完整便于按目录查阅和二次开发。目前已有294人学习下载适合需要完整项目方案、数据库设计或Spring Boot整合MyBatis-Plus实践参考的开发者。1. 基于SpringBootMyBatis-PlusLayui的餐厅点餐系统课程设计就该这么做看一眼这个标题就知道你大概率在赶JavaWeb课程设计。SpringBoot负责把工程跑起来MyBatis-Plus把增删改查从几十行SQL压缩成一行方法调用Layui则让你不碰node、不写构建脚本把后台管理的表格和表单铺满页面。这三个技术凑在一起不是偶然——它们恰好覆盖了课设评委最看重的三件事工程结构规范、数据操作清晰、页面能演示。更实际的是这套组合的资料密度极高遇到问题搜得到答案答辩时每个点都能讲出为什么这么选。餐厅点餐系统这个题目本身也不复杂核心就围绕菜单、餐桌、订单、明细四张表转。本文按数据模型、后端、前端、排错的顺序把一套能跑通的完整方案拆开讲最后给一个不用forEach自己数订单的统计技巧。2. 先把数据模型立住餐厅点餐系统的表设计与关联关系2.1 从下单动作倒推核心数据流做课程设计最忌讳上来就建表。我一般先画一遍用户的操作路径顾客进店选桌→浏览菜品→加入购物车→提交订单→后厨看到订单→结账离桌。这个路径里购物车是前端的临时状态刷新就没了真正要落到数据库的是订单和菜品的关系。由此可以确定最核心的三张业务表菜品表卖什么、订单表谁来买、订单明细表买了什么、买了几份。再加上一张管理员表用于登录一张餐桌表用于管理桌位状态整个系统的数据面就闭环了。MyBatis-Plus在这个阶段帮不上忙但表设计的好坏直接决定后面写实体类时是轻松还是痛苦。MyBatis-Plus的默认驼峰映射会把dish_name自动转成dishName所以数据库字段尽量用下划线命名实体类用驼峰命名两边天然对齐不用写一堆TableField。2.2 菜品表与分类表的建表SQL菜品大概率要按分类展示菜单页顶部切分类、下面刷菜品列表。分类表可以单独建也可以用category字段硬塞在菜品表里。课程设计规模不大建议单独建表理由有两点一是前端Layui的表格需要独立的分类管理入口二是在菜品表上做GROUP BY category统计时索引更友好。CREATE TABLE category ( id int(11) NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 分类名称, sort int(11) DEFAULT 0 COMMENT 排序号越小越靠前, create_time datetime DEFAULT CURRENT_TIMESTAMP, 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(100) NOT NULL COMMENT 菜品名称, price decimal(10,2) NOT NULL COMMENT 单价, image varchar(255) DEFAULT NULL COMMENT 图片路径, status tinyint(1) DEFAULT 1 COMMENT 1在售 0下架, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品表;两张表通过category_id关联。注意price用decimal(10,2)不要用float这是做金额字段的基本要求——浮点数在累加时会出精度问题你也不希望在答辩演示时看到订单总价算错。status字段用tinyint(1)为后面“下架菜品不影响历史订单”做准备查询时默认只查status 1的。2.3 订单主表与明细表为什么必须拆成两张顾客一次可能点五道菜订单主表存一次下单行为订单明细表存五条记录。如果不拆有两种丑陋的做法一是把菜品名和数量拼成一个长字符串存单字段查询时全部拖出来切分二是每道菜一行订单记录导致“订单状态”这种一对多的字段大量冗余。拆表之后订单主表管状态流转明细表只管“这份订单里有什么”各自职责干净。CREATE TABLE orders ( id int(11) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号前端展示用, table_id int(11) NOT NULL COMMENT 餐桌ID, total_amount decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 总金额, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2制作中 3已完成 4已取消, remark varchar(255) DEFAULT NULL COMMENT 备注, create_time datetime DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) 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, dish_name varchar(100) NOT NULL COMMENT 冗余菜品名防止菜品被删后查不到, price decimal(10,2) NOT NULL COMMENT 下单时单价快照, quantity int(11) NOT NULL DEFAULT 1 COMMENT 数量, subtotal decimal(10,2) NOT NULL COMMENT 小计, PRIMARY KEY (id), KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;两个设计细节值得留意。第一明细表冗余了dish_name和price这叫快照。菜品表里的价格是可以改的如果不冗余历史订单的金额会被“篡改”。第二订单号加唯一索引业务上每次生成都带时间戳和随机数重复概率极低数据库这层是最后兜底。这两点答辩时主动讲出来比背概念有用得多。2.4 状态字段用数字常量还是枚举类status字段我用tinyint存数字但在 Java 代码里不直接写魔法值。MyBatis-Plus 有枚举处理方案但课程设计阶段不必上注解式枚举一个简单的常量类就够。在实体类Orders里直接在类的顶部用public static final int STATUS_PAID 1;定义常量写业务逻辑时if (orders.getStatus() Orders.STATUS_PAID)可读性足够。等以后项目复杂了再迁移到 MyBatis-Plus 提供的EnumValue也不迟。3. 后端工程搭建SpringBoot集成MyBatis-Plus的落地细节3.1 依赖引入与application.yml配置用 IDEA 的 Spring Initializr 建工程时Java 版本和 SpringBoot 版本要匹配。MyBatis-Plus 官方提供的mybatis-plus-boot-starter在 SpringBoot 2.x 下用 3.5.x 版本最稳如果选了 SpringBoot 3.x注意javax.servlet已经迁移到jakarta.servlet部分老教程的代码会直接编译不过。课程设计库里十份样例有九份是 SpringBoot 2.x稳妥起见选 2.7.x。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependencymysql-connector-j是 MySQL 官方在 8.0.31 之后的新坐标老教程里的mysql-connector-java已经被它替代。配置application.yml时最容易出问题的是时区参数国内环境固定写serverTimezoneAsia/Shanghai不要写UTC否则create_time会比北京时间慢 8 小时答辩现场看到时间对不上会很尴尬。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/restaurant_order?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: root 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: 0map-underscore-to-camel-case是 MyBatis-Plus 的看家配置开启后数据库create_time自动映射实体类createTime不需要任何注解。log-impl配成StdOutImpl控制台会打印每条 SQL 和参数调试阶段必开项目交付前可以关掉。逻辑删除的三行配置现在可以不用因为表里没建deleted字段但先留着后面扩展时不用改配置文件。3.2 实体类与Mapper写代码的时间能压缩到什么程度MyBatis-Plus 的价值在课设里体现得最彻底的就是实体类和 Mapper。实体类用 Lombok 的Data省掉 getter/setter用TableName声明表名主键用TableId(type IdType.AUTO)匹配数据库自增。需要注意Orders是数据库关键字虽然 MySQL 里order能勉强用但TableName(orders)已经规避了这个问题。Data TableName(dish) public class Dish { TableId(type IdType.AUTO) private Integer id; private Integer categoryId; private String name; private BigDecimal price; private String image; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; }Mapper 接口继承BaseMapperT后单表 CRUD 一条 SQL 都不需要写。selectById、selectList、insert、updateById、deleteById这些方法直接继承过来用。只有多表关联查询才需要自己写 SQL而这正是 MyBatis-Plus 的Select注解配合 XML 文件发挥作用的地方。如果项目里需要菜品表和分类表的联查我一般直接在 Mapper 接口方法上加注解Mapper public interface DishMapper extends BaseMapperDish { Select(SELECT d.*, c.name AS category_name FROM dish d LEFT JOIN category c ON d.category_id c.id WHERE d.status 1) ListDishVO selectDishWithCategory(); }这里返回一个 VO 类包含Dish的所有字段和额外的categoryName。写Select注解时SQL 里的d.*和c.name拼接顺序会影响结果映射resultType能自动映射同名字段多出来的category_name需要 VO 里有categoryName属性靠驼峰映射转过去。3.3 菜品分页查询PaginationInnerInterceptor 的配置与常见误区Layui 的 table 组件默认传page和limit两个参数后端接口最好返回{ code:0, msg:, count:总数, data:当前页数据 }这种结构Layui 才能正确渲染分页条。MyBatis-Plus 的分页功能不是默认开启的必须先注入分页插件很多人在这里漏配导致selectPage查出来的记录数是全表。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }DbType.MYSQL告诉分页插件要生成 LIMIT 语句方言写错会直接报错。setMaxLimit(500L)是防手滑参数防止有人把limit传成 10000 把数据库拖垮。Service 层调用分页时PageDish对象作为第一个参数传入selectPage执行后page.getRecords()拿到当前页数据page.getTotal()拿到总数。public PageDish listDish(long page, long limit, String name) { LambdaQueryWrapperDish wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(name), Dish::getName, name) .orderByAsc(Dish::getCategoryId); return dishMapper.selectPage(new Page(page, limit), wrapper); }LambdaQueryWrapper用方法引用Dish::getName代替字符串字段名好处是编译期就能发现字段拼写错误运行时不会出现Unknown column xxx的怪问题。.like(condition, column, value)第一个参数是布尔条件名字没传就不拼接这个条件Service 层不用写if判断。3.4 点餐下单的 Service 层事务明细表怎么和主表保持一致点餐的核心操作是同时往orders和order_detail各插数据。任何一步失败都要整体回滚否则会出现订单主表有记录、明细表为空的白板订单。Transactional注解能保证这一点但有个前提必须由 Spring 容器管理的 bean 调用才生效在同一个类的内部方法之间调this.save()是不会走代理的。Service RequiredArgsConstructor public class OrderService { private final OrderMapper orderMapper; private final OrderDetailMapper orderDetailMapper; private final DishMapper dishMapper; Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderRequest req) { Orders order new Orders(); order.setOrderNo(generateOrderNo()); order.setTableId(req.getTableId()); order.setStatus(Orders.STATUS_UNPAID); order.setRemark(req.getRemark()); BigDecimal total BigDecimal.ZERO; for (OrderItem item : req.getItems()) { Dish dish dishMapper.selectById(item.getDishId()); if (dish null || dish.getStatus() 0) { throw new RuntimeException(菜品不存在或已下架: item.getDishId()); } OrderDetail detail new OrderDetail(); detail.setDishId(dish.getId()); detail.setDishName(dish.getName()); detail.setPrice(dish.getPrice()); detail.setQuantity(item.getQuantity()); detail.setSubtotal(dish.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); orderDetailMapper.insert(detail); total total.add(detail.getSubtotal()); } order.setTotalAmount(total); orderMapper.insert(order); return buildVO(order); } }rollbackFor Exception.class是关键默认情况下Transactional只对RuntimeException回滚Exception的受检异常不会触发回滚。循环里每次都要查一次菜品价格是为了拿到数据库里的最新价而不是信任前端传过来的金额——前端传price的接口等于把你的收款逻辑暴露给任何人改。明细插入时还没有order_id因为主表 ID 是自增的要等orderMapper.insert(order)执行后才能拿到所以循环里先插明细、后插主表的顺序是不对的。正确做法是先在Orders对象里设置一个假的tableId和状态insert完成后order.getId()才有值再回头把orderId回填给明细。上面代码用insert后的order.getId()可以在插入明细前先拿到主键前提是插入明细的循环放在主表 insert 之后或者先插入主表再循环明细。上面代码缩进不对需要调整——先插主表拿 ID再循环插明细。3.5 Controller 返回结构统一让 Layui 和 Postman 都舒服Controller 层输出 JSON 时统一封装一个ResultT类code 表示状态、msg 表示提示、data 存数据。这层封装不是为了好看是 Layui 的 table 组件硬性要求——它默认读取返回 JSON 里的code、msg、count、data四个字段字段名不对表格就空白。Data public class ResultT { private Integer code; private String msg; private Long count; private T data; public static T ResultT success(T data, Long count) { ResultT r new Result(); r.setCode(0); r.setMsg(); r.setCount(count); r.setData(data); return r; } }code0表示成功Layui 默认成功码就是 0。自己做接口测试时用 Postman 或 Apifox 调GET /api/dish/list?page1limit10返回结构应该是{code:0,msg:,count:32,data:[...]}这样的格式。前后端联调时先拿这个接口验证通不通再看页面能省很多排查时间。4. 前端不必上 VueLayui 把页面和接口串起来的工作台4.1 页面骨架不用 npm 不用 node 的前端工程Layui 最大的好处是零构建下载官方 dist 包丢进static目录就能用。页面里引入layui.js和layui.css然后通过layui.use按需加载模块。SpringBoot 的src/main/resources/static目录会自动映射根路径所以layui.js放在static/lib/layui/下页面直接写相对路径引用即可。确保模板引擎用的是 Thymeleaf 或者干脆纯静态 HTMLSpringBoot 默认会把static下的文件当作静态资源不需要任何额外配置。4.2 table 模块渲染菜品列表分页参数和数据格式的对接菜品管理页的核心是表格。Layui 的table.render会向url发 GET 请求自动带上page和limit参数。后端返回的Result结构需要parseData转成 Layui 期望的方式。layui.use([table, layer], function () { var table layui.table; table.render({ elem: #dishTable, url: /api/dish/list, page: true, cols: [[ { field: id, title: ID, width: 60 }, { field: name, title: 菜品名称 }, { field: price, title: 价格, width: 90 }, { field: categoryName, title: 分类 }, { field: status, title: 状态, width: 80, templet: function (d) { return d.status 1 ? 在售 : 下架; }}, { fixed: right, title: 操作, toolbar: #barDemo, width: 130 } ]], parseData: function (res) { return { code: res.code, msg: res.msg, count: res.count, data: res.data }; } }); });parseData把后端的统一返回格式转成 Layui 内部结构。templet是模板函数用于把 0/1 数字变成可读文本。刚开始做的时候最容易踩的坑是code没转成 0Layui 会提示数据格式错误。limit默认 10后端分页插件会正确处理。4.3 form 模块与 select 动态赋值新增菜品时分类下拉怎么填新增菜品需要选择所属分类分类数据来自接口。select下拉可以写死在 HTML 里但更好的做法是页面初始化时用 jQueryLayui 自带请求分类接口然后把数据填充进select。填完之后必须调用form.render(select)重新渲染否则新加的option不显示这是 Layui 最常见的“我明明加了数据为什么看不到”的坑。form classlayui-form lay-filterdishForm div classlayui-form-item label classlayui-form-label菜品名称/label div classlayui-input-block input typetext namename required lay-verifyrequired placeholder请输入名称 classlayui-input /div /div div classlayui-form-item label classlayui-form-label所属分类/label div classlayui-input-block select namecategoryId idcategorySelect lay-verifyrequired option value请选择分类/option /select /div /div div classlayui-form-item div classlayui-input-block button classlayui-btn lay-submit lay-filtersaveDish保存/button /div /div /formfunction loadCategories() { $.get(/api/category/list, function (res) { var html option value请选择分类/option; var data res.data || []; for (var i 0; i data.length; i) { html option value data[i].id data[i].name /option; } $(#categorySelect).html(html); form.render(select); }); } form.on(submit(saveDish), function (data) { var field data.field; $.post(/api/dish/save, field, function (res) { if (res.code 0) { layer.msg(保存成功, { icon: 1 }); table.reload(dishTable); } else { layer.msg(res.msg, { icon: 2 }); } }); return false; });lay-submit的按钮点击后form.on(submit(saveDish))监听的事件里data.field是表单里所有name属性的键值对。$(#categorySelect).html(html)是普通 jQuery 操作不调用form.render(select)就白费。每次 reload 表格或重新渲染表单后都要检查是否需要再次调用form.render。4.4 laytpl 模板渲染点餐卡片购物车的前端状态维护点餐界面不用表格用卡片更直观。Layui 的laytpl是内置模板引擎能写循环和判断。购物车的数据放在 JS 数组里点击菜品卡片就把{dishId, name, price, quantity}push 进去然后重新渲染购物车区域。这个状态不用提交后端等顾客点“去结算”时才把整个数组 POST 到/api/order/create。script typetext/html idcartTpl ul {{# layui.each(d.items, function(index, item){ }} li span{{ item.name }}/span span{{ item.price }} x {{ item.quantity }}/span button onclickremoveFromCart({{ item.dishId }})删除/button /li {{# }); }} /ul /script使用laytpl.render把模板和数据绑定var cart []; function addToCart(dish) { var found cart.find(function (item) { return item.dishId dish.id; }); if (found) { found.quantity; } else { cart.push({ dishId: dish.id, name: dish.name, price: dish.price, quantity: 1 }); } renderCart(); } function renderCart() { laytpl($(#cartTpl).html()).render({ items: cart }, function (html) { $(#cartBox).html(html); }); }layui.each是 Layui 封装的循环函数模板里的{{# }}是 JS 逻辑区域不是 HTML 输出。购物车用前端数组维护刷新页面就丢课程设计不需要持久化购物车但如果想加分可以把cart存进localStorage下次进入页面时恢复。提交订单时把{ tableId: 3, items: cart }用JSON.stringify发给后端后端用RequestBody接收。5. 数据库导入、版本冲突与统计优化交付前要处理好的三个细节5.1 MySQL 数据库导入同步与 utf8mb4 乱码拿到别人给的.sql文件时常见的问题是本地 MySQL 版本和对方的导出环境不一致。用 Navicat 导入时如果报错Unknown collation: utf8mb4_0900_ai_ci多半是本机 MySQL 是 5.7而 SQL 文件是从 8.0 导出的。解决办法是全局替换 SQL 文件里的utf8mb4_0900_ai_ci为utf8mb4_general_ci。另一种常见问题是导入后中文乱码检查连接串是否带characterEncodingutf8同时确认my.ini里的character-set-serverutf8mb4。如果只是课程设计演示直接用 Navicat 的“数据传输”功能把对方的库整体同步到本地比手动执行 SQL 文件更省事——它会自动处理建表和索引。5.2 SpringBoot 版本太高导致的 MyBatis-Plus 兼容问题标题里的idea创建springboot项目热搜说明很多人第一步就卡住了。IDEA 默认创建的 SpringBoot 版本会跟随当前最新稳定版如果你选了 3.2.x 甚至 3.3.x再用mybatis-plus-boot-starter3.5.3.1会报Failed to process import candidates或扫描不到 Mapper。原因不是 MyBatis-Plus 不能用而是 SpringBoot 3 的自动装配机制发生了变化。两个选择一是把 SpringBoot 版本降到 2.7.18这是 2.x 的最后一个版本生态兼容性最有保障二是换用mybatis-plus-spring-boot3-starter这个为 SpringBoot3 单独发布的依赖。课程设计阶段推荐第一个方案因为在 2.7.18 下踩不到jakarta命名空间迁移的坑网上参考资料也最全。5.3 菜品销量统计用 GROUP BY 替代 Java 层的 for 循环演示系统时评委常会问“哪些菜卖得最好”。用 MyBatis-Plus 的 wrapper 写不出聚合查询这时在OrderDetailMapper里加一个自定义方法用 SQL 做统计而不是查全表再去 Java 里循环。Select(SELECT di.dish_id AS dishId, d.name AS dishName, SUM(di.quantity) AS totalQuantity FROM order_detail di LEFT JOIN dish d ON di.dish_id d.id GROUP BY di.dish_id, d.name ORDER BY totalQuantity DESC LIMIT #{limit}) ListDishRankVO rankDishes(int limit);聚合操作交给数据库前端拿到ListDishRankVO直接渲染成排行榜。这个查询注意GROUP BY的字段要和SELECT的非聚合字段一致MySQL 默认开启了ONLY_FULL_GROUP_BY从 MySQL 5.7 起只按di.dish_id分组时查询d.name会直接报错。验证是否生效可以打开 Navicat 查询窗口先执行一遍 SQL再放到Select注解里。最后别忘了给order_detail.dish_id建索引销量统计和分类筛选都是高频率查询全表扫描在演示数据量下看不出来但在答辩的 JProfiler 或日志监控截图里能吹一下。本文还有配套的精品资源点击获取