
简介面向JavaWeb课程设计与毕业设计的餐厅网络点餐系统完整项目基于SpringBoot集成MyBatis-Plus与Layui组合开发覆盖员工每日9点前在线点餐、经理维护菜品与历史菜单、厨房主管汇总打印订单等真实业务闭环适合具备Java基础、希望快速上手企业级项目开发流程的学生或初级开发者参考学习。资源包共913个文件压缩后约6.49MB内部以Java源码、HTML页面、CSS样式、JavaScript脚本以及SQL数据库文件为主体内容涵盖前端页面、后端接口、数据库设计等多个层面并配有大量png/jpg菜品图片与项目配置文件可在本地IDE中直接还原运行环境。已有294人学习下载。除前端后台源代码与数据库初始化脚本外还附带了静态资源映射和文件上传路径的修改指引能提前绕开部署过程常见坑点代码按controller、service、dao等分层组织业务注释清晰菜单留痕、订单汇总等核心模块独立易读后期扩展或二次开发方便亦可直接作为课程设计或期末项目成果提交。1. 餐厅点餐系统拆解SpringBoot 课程设计的边界与选型拿到这个 SpringBootMyBatis-PlusLayui 的餐厅网络点餐系统时如果 README 只说“源码数据库”最容易卡住你的其实不是业务逻辑而是图片映射路径。启动前先把WebMvcConfig和CommonController里两处写死的本地路径改成当前项目 picture 目录后面的功能才看得到。这是一套典型 javaweb 课程设计餐厅经理维护菜谱每周生成菜单员工在上午 9 点前按菜单下单9 点后厨房主管打印总括订单输出。下面会把数据模型、后端接口、Layui 渲染到部署验证这条链路讲清楚适合正在做数据库课程设计或 SpringBoot 练手的人直接对照复现。2. 菜单与订单的数据模型recipe、menu、order 三张表的拆分逻辑餐厅点餐类课程设计里最常见的问题是把菜品直接塞进订单表用一个food_ids字符串接收查历史订单时再手动拆分。这份项目没有这样做而是把主数据和业务快照分开。刚开始改代码时可能觉得表多实际对“每周生成一次菜单、每日 9 点截止下单”这种业务约束拆表才是最简单可维护的方案。2.1 菜谱是主数据菜单是每周快照菜谱相当于餐厅内部的全体菜品字典经理维护一次之后长期存在每周菜单是从菜谱中挑出来的一批菜发给员工点。如果把“本周有哪些菜”直接写在订单里下一周菜单变化后历史订单就无法正确还原。所以这里至少需要三张基础表recipe存全体菜品menu存某一周生成的菜单menu_item存该菜单包含了哪些 recipe。字段设计可以做成下面这样表关键字段作用注意事项recipeid, name, category, price, unit, image_url菜品基础字典category 建议用固定值菜肴、点心、主食、糖水menuid, week_start, status每周生成的菜单头保留历史菜单不物理删除menu_itemid, menu_id, recipe_id, sort_no菜单和菜品的关联单价不冗余在这里查询时 join recipe 拿最新价课程设计里没必要引入完整的菜品分类表用字符串字段加固定枚举即可。但如果你想把“菜单快照价格”留档可以在menu_item冗余price作为当周价格。实际这份项目把价格主要放在 recipemenu_item 只做关联历史价格通过订单明细里的快照解决。建表 SQL 的关键部分如下CREATE TABLE recipe ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL COMMENT 菜品名, category VARCHAR(16) NOT NULL COMMENT 菜肴/点心/主食/糖水, price DECIMAL(8,2) NOT NULL COMMENT 单价, unit VARCHAR(8) DEFAULT 份 COMMENT 计量单位, image_url VARCHAR(255) DEFAULT COMMENT 菜品图片相对路径, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE menu ( id BIGINT AUTO_INCREMENT PRIMARY KEY, week_start DATE NOT NULL COMMENT 周一日期用于定位周菜单, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE menu_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, menu_id BIGINT NOT NULL, recipe_id BIGINT NOT NULL, sort_no INT DEFAULT 0, UNIQUE KEY uk_menu_recipe(menu_id, recipe_id) );这里唯一键uk_menu_recipe的目的是防止同一周菜单里重复引入同一个菜。很多课程设计省略这个唯一键结果经理在界面上多选一次菜单里出现两道“红烧肉”。加唯一键之后MyBatis-Plus 插入时如果重复会抛 DuplicateKeyException在 controller 里捕获后给 Layui 提示“该菜已在菜单中”即可。2.2 订单拆成主表和明细表价格存快照员工下单是一个“订单头 多行明细”的结构。只用一张 order 表存多个recipe_id的做法在打印厨房总括订单时需要按菜品维度汇总写出来的 SQL 往往是一长串FIND_IN_SET或循环拆分维护成本很高。拆成两张表order_form记录某员工某天的一顿饭核心字段是user_no、order_date、status、remarkorder_item记录这一单里的每一道菜核心字段是order_id、recipe_id、recipe_name、price、quantity。为什么明明可以 join recipe 拿名字和价格还要把recipe_name和price冗余一份因为历史订单要永久留档下周如果厨师调价join 出来的就是新价格历史账单全部失真。把下单那一刻的名称、价格、计量单位写进明细表打印什么就是当时看到什么。后面做财务对账也只查订单明细不影响菜谱维护。关键约束如下CREATE TABLE order_form ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_no VARCHAR(32) NOT NULL COMMENT 员工工号, order_date DATE NOT NULL COMMENT 下单日期, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待处理 1已出餐, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_order_date(user_no, order_date) ); CREATE TABLE order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, recipe_id BIGINT NOT NULL, recipe_name VARCHAR(64) NOT NULL COMMENT 下单时菜名快照, price DECIMAL(8,2) NOT NULL COMMENT 下单时单价快照, quantity INT NOT NULL DEFAULT 1, unit VARCHAR(8) DEFAULT 份 );可能有人会问order_form加这个唯一键之后员工在 9 点前重新点餐怎么办常见处理是再次提交时先按user_no order_date删除旧单再插入新单整个操作包在一个事务里。这样既保证一人一天只有一单又不需要给订单加复杂的“已支付/未支付”状态。数据库层面的唯一键是最后防线不要只在 service 里用 if 判断。2.3 MyBatis-Plus 实体映射与自动填充表建好后用 MyBatis-Plus 映射实体。这份项目里普遍是TableName加TableId主键策略用ASSIGN_ID或自增都行。订单明细的实体大致如下TableName(order_item) public class OrderItem { TableId(type IdType.AUTO) private Long id; TableField(order_id) private Long orderId; TableField(recipe_id) private Long recipeId; private String recipeName; private BigDecimal price; private Integer quantity; private String unit; }这里需要注意TableField的使用。MyBatis-Plus 默认开启驼峰转下划线orderId能自动映射order_id所以很多字段不写注解也能跑但项目里常见风格是把字段名写清楚避免后期改数据库列名后难以排查。createTime这类字段一般交给数据库DEFAULT CURRENT_TIMESTAMP处理实体里不设值插入时 MyBatis-Plus 会忽略 null数据库自动补时间。如果你要自己在 service 里手动设置创建时间可以使用 MetaObjectHandlerComponent public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); } }使用自动填充的好处是所有写操作统一走同一个入口不会出现某个接口漏填createTime导致订单排序异常。在这种 javaweb 课程设计里统一填充比在每一个 service 里手写now()可靠得多。数据模型确定后后端接口基本就是围绕这套表结构做增删改查下一章看 controller 层的具体实现。3. 后端接口链路MyBatis-Plus CRUD、文件上传与静态资源映射3.1 Layui 表格的响应格式与分页参数转换Layui 的 table 模块默认请求格式是?page1limit10而不是 JPA 风格的page/pageSize。MyBatis-Plus 分页插件接收的是current和size所以 controller 里拿到page后要转成current page、size limit。返回体也必须是{code:0, msg:, count:total, data:rows}这个结构否则表格只会报“数据异常”。项目里一般有自己的 Result 包装类建议再构造一个 LinkedHashMap 把字段顺序固定住避免 JSON 序列化顺序问题。代码示例GetMapping(/menu/current/items) ResponseBody public MapString, Object currentMenuItems(RequestParam(defaultValue 1) long page, RequestParam(defaultValue 10) long limit, String category) { LambdaQueryWrapperMenuItemVO wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(category)) { wrapper.eq(MenuItemVO::getCategory, category); } PageMenuItemVO result menuItemService.page(new Page(page, limit), wrapper); MapString, Object resp new LinkedHashMap(); resp.put(code, 0); resp.put(msg, ); resp.put(count, result.getTotal()); resp.put(data, result.getRecords()); return resp; }这里的page参数来自 Layui 的默认分页组件limit对应每页条数。分类筛选通过category条件拼进 wrapper不匹配时返回空数据集Layui 会自动显示“无数据”。实际课程设计前后端联调时问题多半死在 count 是字符串还是 Long 上Layui 对count的兼容性比较宽松但最好统一返回 Long。3.2 点餐服务的 9 点截止与事务控制需求里的关键规则是员工每天上午 9:00 之前登录选菜9 点以后厨房主管开始打印总括订单。前端按钮可以隐藏甚至可以弹窗但后端必须再次校验系统时间。只靠前端setInterval去禁用按钮很容易被绕过直接模拟请求就能打接口。处理方式是在OrderController的创建订单接口里用LocalTime.now()和配置的截止时间比较。这个截止时间不要写死在 service 里放到application.yml的canteen.order.deadline配置项中方便课程设计演示时临时改到 23:59:59。SpringBoot 配置通过Value注入 controller 或 service。PostMapping(/order) ResponseBody Transactional(rollbackFor Exception.class) public Result createOrder(RequestBody OrderSubmitDTO dto) { LocalTime now LocalTime.now(); if (now.isAfter(deadline)) { return Result.error(已超过截止时间 deadline 订单无法提交); } // 删除旧订单再插入新订单 orderItemService.deleteByUserIdAndDate(dto.getUserNo(), LocalDate.now()); OrderForm form buildForm(dto); orderFormService.save(form); ListOrderItem items buildItems(dto.getItems(), form.getId()); orderItemService.saveBatch(items); return Result.success(form.getId()); }逻辑说明deadline必须是后端服务时间而不是页面传过来的时间一旦前端拿到请求缓存或做了时区转换判断会有偏差。这里先删除旧单再插入并发问题被数据库唯一键兜底如果同一用户在 9 点前瞬间发两次请求第二次插入会撞uk_user_order_date事务回滚后不会产生两单。注意deleteByUserIdAndDate和saveBatch必须在同一事务里否则第二次失败会把旧订单也删掉用户单子直接丢了。3.3 图片上传与静态资源映射两处路径必须改源码说明里明确提到需要在WebMvcConfig设置静态资源映射把file:C:/Users/30141/IdeaProjects/canteen/picture/改成当前项目的 picture 路径同时CommonController文件上传接口里的 Windows 绝对路径也要改。这一步是所有拿到源码的人必踩的第一个坑。上传接口的代码通常长这样PostMapping(/common/upload) public Result upload(RequestParam(file) MultipartFile file) { String uploadPath C:\\Users\\30141\\IdeaProjects\\canteen\\picture\\; File dir new File(uploadPath); if (!dir.exists()) { dir.mkdirs(); } String filename UUID.randomUUID() _ file.getOriginalFilename(); file.transferTo(new File(uploadPath filename)); return Result.success(/picture/ filename); }这里的绝对路径是作者本机 IDEA 的 IdeaProjects 路径换到其他电脑后要么目录不存在要么没有写权限。我的建议是不要只改字符串而是用 SpringBoot 配置外置启动时通过启动参数覆盖。canteen: picture-path: ${CANTEEN_PICTURE_PATH:./picture}静态资源映射在WebMvcConfig中对应改动Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String location canteenPicturePath; if (!location.endsWith(/)) { location location /; } registry.addResourceHandler(/picture/**) .addResourceLocations(file: location); }/picture/**是 URL 访问前缀file:前缀告诉 Spring Boot 从文件系统加载资源而不是从 classpath 加载。如果路径写成classpath:/picture/图片在开发环境能显示部署成 jar 后图片上传到项目同级目录时又会找不到。课程设计大多在 IDEA 里跑用./picture相对项目根目录即可如果打成 jar推荐在 jar 同级的config/application.yml里单独指定绝对路径避免每次启动都要改代码。4. Layui 前端渲染菜单列表、点餐表单与总括订单4.1 菜单表格与点餐请求前端页面可以直接用 Layui 封装好的 table 组件。先定义一个table.render把菜单接口喂给页面table.render({ elem: #menuTable, url: /menu/current/items, page: true, cols: [[ { field: name, title: 菜品, minWidth: 120 }, { field: category, title: 分类, width: 90 }, { field: price, title: 单价, width: 90 }, { field: unit, title: 单位, width: 70 }, { field: image, title: 图片, width: 120, templet: function(d){ return img src d.imageUrl stylewidth:60px;height:60px /; }}, { field: quantity, title: 数量, width: 120, edit: text } ]], done: function(res) { if (res.count 0) { layer.msg(本周菜单未生成); } } });edit: text可以让用户在表格里直接改数量但后端拿到的是字符串提交前要用parseInt转成整数。templet函数返回图片标签时注意imageUrl的值是/picture/xxx.jpg配合前一章的静态资源映射才能显示。如果图片 404先看 network 里图片请求的路径是不是http://localhost:8080/picture/xxx.jpg。点餐提交按钮绑定在保存按钮上把表格数据通过table.getData(menuTable)拉出来过滤掉数量为空的记录再$.post(/order, JSON.stringify(data))提交。这里有一个细节getData拿到的数据会根据每行主键有变动最好把menuItemId作为行号传给后端后端通过 menu_item 关联 recipe_id避免前端传 recipeId 被人为篡改。4.2 菜品分类筛选与 layui select 动态赋值页面上常有一个分类下拉框全部、菜肴、点心、主食、糖水。下拉框的数据来源是/recipe/categories后端从 recipe 表select distinct category查出来。这个功能不复杂前端却经常踩 layui select 动态赋值的坑——设置了option但下拉框不显示。$.get(/recipe/categories, function (res) { var html option value全部/option; res.data.forEach(function (item) { html option value item item /option; }); $(#categorySelect).html(html); form.render(select); });form.render(select)是必须的。Layui 在页面加载时会把select渲染成自定义样式之后 JS 修改 option 内容它内部并不知情必须通过 render 重新渲染。很多人的 select 动态赋值写完值一变列表就空就是漏了这一步。紧接着在form.on(select(categoryFilter))里触发表格重新加载form.on(select(categoryFilter), function (data) { table.reload(menuTable, { where: { category: data.value }, page: { curr: 1 } }); });where里的字段名要和后端 controller 的RequestParam(category)参数名一致。page.curr: 1是为了切换分类后回到第一页否则停留在第 5 页时分类过滤后的数据不够 5 页Layui 会显示空表容易误判。4.3 厨房总括订单的汇总与打印厨房主管打印的是一份总括订单按菜品维度汇总所有员工点餐数量而不是一单一单刷屏。前端页面在点击打印时请求/order/summary/today后端返回类似[{recipeName, quantity, unit}]的结构。前端拿到数据后用一个小函数做二次加固function buildSummaryRows(list) { var map {}; list.forEach(function (item) { var key item.recipeName | item.unit; if (!map[key]) { map[key] { recipeName: item.recipeName, unit: item.unit, total: 0 }; } map[key].total item.quantity; }); return Object.values(map); }这里的quantity是明细表的份数而不是行数。有的同学在数据库里写重复行一个菜一行汇总时直接list.length点 3 份鱼香肉丝就只算成 1 份。务必让后端 SQL 用SUM(quantity)聚合前端这段 map 只是兜底。打印主体可以放到新窗口中var printWindow window.open(, _blank); printWindow.document.write(htmlheadtitle厨房总括订单/title/headbody); printWindow.document.write(h3 new Date().toLocaleDateString() 总括订单/h3); printWindow.document.write(document.getElementById(summaryContent).innerHTML); printWindow.document.write(/body/html); printWindow.document.close(); printWindow.print();不要直接把整张页面打印出去会带上左侧菜单和表单按钮。只打印summaryContent这个容器即可同时给打印页面套上一个 800px 的表格样式避免跨页断行。5. 启动前的路径修正与 9 点截止校验的验证方法5.1 在 IDEA 里改两处绝对路径拿到源代码后按“先跑通图片再点餐再打印”的顺序核查。打开WebMvcConfig找到addResourceHandlers把file:C:/Users/30141/IdeaProjects/canteen/picture/改为当前项目 picture 目录的绝对路径例如file:D:/projects/canteen/picture/。如果写成相对路径要确认当前工作目录是项目根目录。打开controller/CommonController找到文件上传方法把C:\Users\30141\IdeaProjects\canteen\picture\同样替换为上面设置的路径。在application.yml里检查spring.datasource的用户名密码并导入项目附带的.sql数据库文件。启动后访问http://localhost:8080/picture/测试图片.jpg能看到图片说明静态资源映射成功。这里的“当前项目的 picture 对应的本地路径”在 IDEA 里可以直接右键 picture 目录选择 Copy Path再拼上file:前缀。5.2 验证 9 点截止的三种方式要测试“9 点后不能下单”的逻辑不建议改操作系统时间干扰因素太多。可以在application.yml里把截止时间临时设置成23:59:59这样任何时间都能提交再改成00:00:00验证所有提交都会被拒绝。如果代码里没有读取配置项而是写死LocalTime.of(9, 0)那么最直接的方式是改数据库order_form里的order_date制造一条 9 点前的历史订单来验证删除旧单逻辑。也可以加一个CommandLineRunner启动时输出当前服务的默认时区。LocalTime.now()依赖 JVM 时区容器时区和宿主机不一致时9 点判断容易出现一小时偏差。5.3 常见排错静态资源 404、表格空白、SpringBoot 版本太高如果 Layui 表格显示“数据异常”先看浏览器 network 返回的 JSONcode是否不是 0。多数情况是 MyBatis-Plus 分页插件没有注册或者 controller 返回体用了Page对象而前端取到的是records不是data。如果静态资源 404确认路径前缀匹配registry.addResourceHandler(/picture/**).addResourceLocations(file: uploadPath /)uploadPath结尾一定要带/否则 Spring 拼接资源路径时会把picture和文件名粘在一起。另外提一句 springboot 版本太高时的现象老项目用spring-boot 2.3.x的内嵌 Tomcat在 Spring Boot 3.x 上跑会出现javax.servlet变jakarta.servlet的编译错误MyBatis-Plus 也需要换mybatis-plus-spring-boot3-starter。课程设计里不要因为追求新而升版本保持项目原本的 Boot 版本省下的时间够你把 9 点截止校验和联合唯一约束讲清楚。验证最后一步用两个员工账号分别下单再以厨房主管身份打开总括订单确认列表出现合并后的菜品数量和单价小计然后把其中一个员工的order_date改成昨天查看今日汇总是否不再包含该数据这个改动直接反映menu_item与order_item的日期 join 是否正确。本文还有配套的精品资源点击获取