简介:以点餐平台网站作为主题的Java毕业设计项目,基于Spring Boot与Vue技术栈实现前后端分离的B/S架构,是一份包含源码、说明与数据库的完整工程资料。项目面向计算机专业学生,适用于毕业设计、课程设计及全栈开发练习,完整覆盖管理员端、用户端和前台首页三大模块,包含菜品分类、菜品信息、订单管理、购物车、在线客服、菜品评价等核心业务功能。压缩包内共1652个文件,整体大小约31.25MB,主要文件类型有java与class后端源码、vue与js前端脚本、html与css静态页面、sql数据库脚本及docx说明文档,可方便查看设计文档与数据库结构。目前已有115人学习下载,资源附带数据库与说明文件,能帮助快速搭建运行环境,并通过完整源码深入理解权限控制、业务流转和前后端交互逻辑,便于二次开发与论文写作。
1. 点餐平台为什么是 Java 毕业设计的“闭环首选”
点餐平台(在线订餐系统)在 Java 毕业设计里属于业务闭环最完整的选题:前台负责菜品展示、购物车、下单、评价,后台负责用户、菜品分类、菜品信息、订单和系统管理,把 SpringBoot 的 ORM、事务控制和 Vue 的路由、组件通信都串了起来。
拿到源码包先确认三件事:数据库脚本能否单独导入、配置文件里的 MySQL 账号密码是否匹配、前后端是否分端口启动。项目里的update-password.vue.bak、IndexAsideStatic.vue.bak等备份,是交付前改过登录页和后台布局留下的痕迹,页面改坏了可以直接用.bak回滚。
适合课程设计赶时间、答辩要现场演示的学生,也适合想快速搭一套带权限和订单流的管理系统骨架的初级工程师。后面按表结构、后端接口、前端交互、权限状态机、部署验证的顺序展开,每步都能在源码里找到对应位置。
2. SpringBoot 后端:数据库表设计与菜品模块的实体映射
先看后端整体结构,标准的三层:Controller 收参数、Service 写业务、Mapper 操作数据库。点餐平台这类项目的核心不在一张表多少字段,而在表与表之间的关联关系怎么建。用户、菜品、订单、评价、收藏这几张表一旦打通,前台展示和后台管理就都能挂在同一套数据模型上。
2.1 点餐系统最少需要几张表
| 表名 | 关键字段 | 在系统里的职责 |
|---|---|---|
user | username、password、role | 普通用户与管理员共用,靠 role 区分(0 用户 / 1 管理员) |
dish_category | name、sort | 菜品分类,前台按分类筛选的依据 |
dish | name、category_id、image、price、sales | 菜品主表,sales 用于按销量排序 |
orders | order_no、user_id、amount、status、address | 订单主表,status 驱动订单流程 |
order_detail | order_id、dish_id、dish_name、number | 下单时的菜品快照,防止菜品改名后订单无据可查 |
dish_comment | dish_id、user_id、star、content | 菜品评价,star 用 1~5 的整数 |
collect | user_id、dish_id | 用户“我的收藏” |
news | title、content、cover | 前台菜品资讯 |
一个直接踩得上的坑:订单主表别叫order,它是 MySQL 的保留字,建表时会报语法错误,源码里用的是orders,课程设计里也建议统一用复数或加tb_前缀。
价格字段我一般建议用「分」存整数,而不是用decimal存元。理由是浮点精度问题,0.1 加 0.2 在计算机里不是 0.3,点餐这种高频加购计算的场景一旦用浮点累加,订单金额最后一位会对不上。用整数分存储,前端展示时除以 100 就行,这个细节也常被用来回答 java 面试题里的金额精度问题。
2.2 实体类与 MyBatis-Plus 的映射关系
源码用的持久层框架是 MyBatis-Plus,实体类通过注解映射到表,不需要写一堆 XML:
@TableName("dish") public class Dish { @TableId(type = IdType.AUTO) private Integer id; private String name; @TableField("category_id") private Integer categoryId; private String image; private Integer price; // 单位:分 private Integer sales; // getter / setter 省略 }@TableName指定实体对应的表名,@TableId(type = IdType.AUTO)声明主键自增,@TableField只在驼峰字段和下划线字段对不上时用。MyBatis-Plus 默认开启驼峰转换,categoryId会自动映射到category_id,所以大部分字段不用额外加注解。
注意:如果改动过数据库字段名,启动后报
Unknown column,先去application.yml确认map-underscore-to-camel-case有没有被改成 false,这是改配置时最容易被误伤的一项。
2.3 菜品分页查询接口的参数设计
前台菜品列表要支持分页、按名称模糊搜索、按分类过滤,Controller 里对应的接口参数如下:
@RestController @RequestMapping("/dish") public class DishController { @GetMapping("/page") public Result page(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "6") Integer pageSize, @RequestParam(required = false) String name, @RequestParam(required = false) Integer categoryId) { Page<Dish> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Dish> qw = new LambdaQueryWrapper<>(); qw.like(StrUtil.isNotBlank(name), Dish::getName, name) .eq(categoryId != null, Dish::getCategoryId, categoryId) .orderByDesc(Dish::getSales); return Result.ok(dishMapper.selectPage(page, qw)); } }| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| pageNum | Integer | 1 | 页码,从 1 开始 |
| pageSize | Integer | 6 | 每页条数,建议设上限 20 |
| name | String | 空 | 菜品名称模糊匹配 |
| categoryId | Integer | 空 | 分类精确过滤 |
like方法第一个条件为 false 时,整段查询条件会被跳过,所以name为空时不会拼WHERE name LIKE '%%'这种低效条件。orderByDesc(Dish::getSales)让销量高的菜品排在前面,前台首页不做额外 SQL 就能拿到热销排序。
我一般会在 Service 层再加一道pageSize = Math.min(pageSize, 20)的兜底,防止前端传 9999 一次性拉全表。分页返回的total、records都来自同一个Page对象,前端 Vue 的分页组件直接绑定total就能正常显示总页数。
3. Vue 前端:菜品展示、购物车与评价的交互实现
前端分成两个区域:面向用户的商城页和面向管理员的控制台。后台布局用的 Element UI,源码里的几个.bak文件正好暴露了布局组件的构成,从这些备份能反推出原项目的页面结构,改起来也有据可依。
3.1 从 .bak 文件推断后台布局结构
| 组件文件 | 职责 | 对应备份 |
|---|---|---|
IndexAsideStatic.vue | 后台侧边菜单,静态版 | IndexAsideStatic.vue.bak |
IndexHeader.vue | 顶部栏,含头像和退出 | IndexHeader.vue.bak |
BreadCrumbs.vue | 面包屑导航 | BreadCrumbs.vue.bak |
update-password.vue | 个人中心修改密码 | update-password.vue.bak |
说明原作者在交付前把侧边栏从动态渲染改成了静态菜单,同时重做了密码修改页。备份文件的作用很直接:改组件前cp IndexHeader.vue IndexHeader.vue.bak,布局改崩了随时还原。答辩时能讲出这层用意,比单纯说“我用了 Vue”更有信息量。
3.2 路由拆分与页面懒加载
// router/index.js const routes = [{ path: '/', component: () => import('@/views/front/Layout.vue'), children: [ { path: '', component: () => import('@/views/front/DishList.vue') }, { path: 'cart', component: () => import('@/views/front/Cart.vue') }, { path: 'order', component: () => import('@/views/front/MyOrder.vue') } ] }, { path: '/admin', component: () => import('@/views/admin/Layout.vue'), children: [ { path: 'dish', component: () => import('@/views/admin/DishManage.vue') }, { path: 'order', component: () => import('@/views/admin/OrderManage.vue') } ] }]路由按用户端和管理员端拆成两个 Layout,各自有独立的侧边栏和头部。() => import()是懒加载,首屏只下载当前路由对应的组件,而不是把整个后台代码一次性打进app.js。vue 打包后布局异常的一个常见原因就是首屏包太大渲染慢,懒加载能顺带缓解这个问题。
注意:
/admin下的页面必须配合登录权限守卫,否则直接输 URL 就能绕过前端菜单进后台。守卫通常写在router.beforeEach里,检查 localStorage 里的登录态和 role,不满足条件就next('/login')。
3.3 购物车的本地状态与下单前校验
购物车这个模块,很多课程设计会建一张cart表,但常见做法是放前端,用 localStorage 持久化:
// store/cart.js const KEY = 'cart_items' export default { namespaced: true, state: { items: JSON.parse(localStorage.getItem(KEY) || '[]') }, mutations: { add(state, dish) { const item = state.items.find(i => i.id === dish.id) if (item) { item.count++ } else { state.items.push({ id: dish.id, name: dish.name, price: dish.price, count: 1 }) } localStorage.setItem(KEY, JSON.stringify(state.items)) }, clear(state) { state.items = [] localStorage.removeItem(KEY) } } }加购、改数量、清空购物车全部在浏览器端完成,刷新页面数据不丢,后端不需要维护中间态数据。要点是下单提交时,商品单价必须以服务端查询结果为准,不能用 localStorage 里的价格直接算总额——用户可以改浏览器里的值,服务端必须重新校验一遍价格和库存。
3.4 菜品评价的提交与重复校验
用户端有“菜品评价管理”,管理员端也能看到评价,评价不是随便发的,必须和订单状态绑定。常见规则是订单完成后才允许评价,防止没消费就刷好评,后端校验:
Order order = orderService.getById(orderId); if (order == null || !order.getUserId().equals(loginUserId) || !"3".equals(order.getStatus())) { return Result.error("订单未完成,不能评价"); } DishComment comment = new DishComment(); comment.setDishId(dishId); comment.setUserId(loginUserId); comment.setStar(star); // 1~5 comment.setContent(content); commentService.save(comment);"3"对应订单状态机里的“已完成”,这个值和第 4 章的状态定义必须一致,前后端任何一处写死成别的数字都会导致评价功能失效。如果再严一点,给dish_comment加上order_id字段,查询时先select count判断是否评过,就能避免同一个订单对同一道菜重复评价。
4. 双端权限与订单状态机:从下单到完成的闭环
用户端和管理员端最核心的差异在权限和数据范围:用户只能看自己的订单和自己的收藏,管理员能看到全部订单且能推进订单状态。这两件事靠一张user表的 role 字段和一套订单状态机就能实现。
4.1 登录态与角色权限的两种做法
课程设计级别的项目,session 加拦截器是最稳的方案,登录成功后把用户对象放进 session,需要管理员权限的接口统一走拦截器:
public class AdminInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object admin = request.getSession().getAttribute("admin"); if (admin == null) { response.setStatus(401); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); return false; } return true; } }注册到WebMvcConfigurer里,只拦截/admin/**路径。这套做法的好处是不用引入 JWT 依赖,session 失效就自动退出,适合演示环境。如果改为 JWT,请求头里带 token,后端每次解析,优点是前后端完全分离、扩展性好,但要在拦截器里自己处理 token 过期。两种实现二选一即可,答辩被问到就讲清楚各自取舍。
4.2 订单状态机的五态流转与防跳变
订单状态是整个项目里最容易出 bug 的地方。直接update orders set status = 新值 where id = ?会允许从“已完成”跳回“已接单”,逻辑上就乱了。规范做法是先定义合法流转:
| 状态值 | 状态含义 | 允许的下一步 |
|---|---|---|
| 0 | 待处理(新订单) | 1 接单、4 取消 |
| 1 | 已接单 | 2 配送中、4 取消 |
| 2 | 配送中 | 3 已完成 |
| 3 | 已完成 | 无(仅可评价) |
| 4 | 已取消 | 无 |
对应的判定逻辑:
private static final Map<Integer, List<Integer>> FLOW = new HashMap<>(); static { FLOW.put(0, Arrays.asList(1, 4)); FLOW.put(1, Arrays.asList(2, 4)); FLOW.put(2, Arrays.asList(3)); } public boolean canTransit(int from, int to) { return FLOW.getOrDefault(from, Collections.emptyList()).contains(to); }管理员接单、发货、完成三个操作分别调用canTransit校验,不合法直接返回“当前状态不可执行该操作”。更新 SQL 也建议带上旧状态条件:
UPDATE orders SET status = 2 WHERE id = #{id} AND status = 1即使两个请求并发进来,只有一条能真正更新成功,这是数据库层面的最后一道防线。状态机防跳变的思路,在 springboot 面试题里也常被拿来问“如何防止业务状态被非法修改”,答上这一层会比只说if判断留下更好的印象。
4.3 订单列表的关联查询与订单明细快照
订单列表要显示用户名、菜品名、金额和状态,一次关联查询搞定:
SELECT o.id, o.order_no, u.username, GROUP_CONCAT(d.dish_name) AS dish_names, o.amount, o.status, o.create_time FROM orders o LEFT JOIN user u ON o.user_id = u.id LEFT JOIN order_detail d ON d.order_id = o.id GROUP BY o.id ORDER BY o.create_time DESCGROUP_CONCAT把同一个订单的多个菜品名拼成一行,适合列表展示。注意user和order这类名字建议加反引号,避免在不同 MySQL 版本下触发保留字问题。订单提交时order_detail里存的是菜品名称和单价快照,之后菜品改价或下架都不影响历史订单的展示,这是订单系统的基本要求。
管理员端的“系统管理”通常是轮播图和菜品资讯这类简单 CRUD,前台“在线客服”在课程设计里一般是页面右下角的对话面板,复杂一点的接 WebSocket 做实时聊天,但为了答辩稳定性,模拟对话比长连接更不容易在演示时翻车。
5. 部署与答辩前的验证清单
5.1 解压后跑通全流程的三个高频坑
# 1. 导入数据库(先建库再导数据) mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS ordering DEFAULT CHARSET utf8mb4;" mysql -uroot -p ordering < sql/ordering.sql # 2. 启动后端 mvn spring-boot:run # 3. 启动前端 cd frontend npm install npm run serve第一个坑是 MySQL 连接报Public Key Retrieval is not allowed,在 jdbc url 上加allowPublicKeyRetrieval=true&useSSL=false即可,本质是 mysql-connector-j 8.x 对加密连接的行为变化;如果 springboot 版本太高导致驱动类加载异常,优先把驱动版本降回和数据库匹配的版本,而不是盲目升级框架。
第二个坑是 vue 打包后布局异常,npm run build出来的页面空白或 CSS 路径 404,多半是静态资源用了绝对路径。在vue.config.js里设publicPath: './',资源改成相对路径就正常了。开发模式下的异常则优先检查 Element UI 版本和 Vue 2/3 是否匹配。
第三个坑是端口占用。后端改application.yml里的server.port,前端通过devServer.proxy把/api代理到后端地址,代理配错的表现是前端能打开但所有请求 404。改页面代码前,按原作者的习惯先cp 目标文件 目标文件.bak,改坏了能立刻还原。
5.2 答辩演示的完整验证路径
演示顺序建议直接沿着业务主线走:管理员登录后新增菜品分类和菜品,前台用户注册登录,把菜品加入购物车并下单,管理员在订单管理中接单、标记配送中、完成订单,用户端确认订单完成后对菜品评价,最后切到管理员端的评价管理查看这条评价。每个关键步骤后,打开数据库客户端查一眼orders和order_detail的记录,让评委看到数据是真实落库的,而不是前端写死的假数据。
准备一个能自圆其说的失败场景也很有用,比如演示时故意用未完成的订单去评价,让接口返回“订单未完成,不能评价”,再解释这是状态机校验在起作用。现场能把一个报错讲清楚,比一路顺畅地跑完更有说服力。答辩前把这三件事验证到位:数据库重新导入一次能成功、前后端从零启动一次能通、核心链路完整走一遍,这份源码就能稳稳撑起整场演示。
本文还有配套的精品资源,点击获取