Spring Boot+Vue点餐系统全栈实战:数据库设计到Nginx部署
2026/9/16 4:38:31 网站建设 项目流程

简介:这是一份基于Spring Boot与Vue.js的网上点餐系统毕业设计项目,主要面向计算机相关专业毕业生、正在准备课程设计或期末作业的学生,也适合想快速掌握前后端分离项目开发的初学者。系统围绕用户点餐、菜品浏览、购物车与订单管理等核心场景展开,前端使用Vue.js实现交互界面,后端基于Spring Boot提供接口服务,并包含完整的数据库脚本,可支撑从环境搭建到功能演示的整个流程。资料包大小约66.37MB,包含项目源码、数据库文件、答辩PPT、毕业论文、使用文档及演示视频,其中演示视频能直观展示系统操作,论文与PPT可作为撰写文档与答辩汇报的参考。该资源已在Windows10/11测试环境中严格调试,确保解压后按部署教程即可运行,属于答辩评审97分的高分项目。目前已有206人学习下载,无论是用于毕业设计二次开发,还是作为期末项目提交,都具有较高的参考价值。

1. 点餐系统拆解:Spring Boot + Vue 各管哪一段

这套网上点餐系统源码,拆开看其实是一个很标准的“前端展示 + 后端交易”项目。Spring Boot 负责菜品管理、下单、支付状态变更和订单查询,Vue 负责把菜牌渲染出来、维护购物车、提交订单并回显状态。数据库用 MySQL,整个链路从页面点击到最后落库,中间隔着一条 REST 接口。对做数据库课程设计或者毕业设计的人来说,它最大的价值不是功能多,而是能完整看到“用户加购物车、提交订单、商家改状态”这条业务线是怎么被切开、又怎么拼回去的。对已经工作几年的开发,这项目反而适合用来检查自己是否漏掉了事务、状态机、跨域代理这些基本功。下面按从下到上的顺序拆。

2. 数据库设计:菜品、订单与状态流转的后端实现

2.1 四张业务表:从 ER 关系到表结构

点餐系统的核心不是“菜”,而是“订单”。用户会点多道菜,订单与菜品是多对多关系,所以必须拆出一张订单明细表。常见的表结构如下:

表名作用关键字段
user用户/会员id, username, password, phone, address
dish菜品id, name, price, image, category, status
orders订单主表id, user_id, order_no, total_amount, status, create_time
order_detail订单明细id, order_id, dish_id, dish_name, price, quantity

为什么订单明细里要冗余dish_nameprice,而不是下单时只存dish_id?因为菜品价格会变。商家今天把宫保鸡丁从 28 改成 32,历史订单里的金额不应该跟着变。明细表里保存下单那一刻的名称和价格快照,后厨出菜、财务对账才说得清楚。另一个原因是统计“哪些菜卖得多”,直接对order_detaildish_id分组求和就行,性能也比关联菜品表再聚合好。

订单号建议用order_no字符串,不要直接把自增 id 暴露给用户。我一般生成ORD + yyyyMMddHHmmss + 4位随机数,比如ORD202506011030123456。这样餐厅服务员对单号时不会因为“id 是 1024 还是 1025”扯皮,也是防爬的一种小手段。

2.2 订单状态机:用整型字段避免文案漂移

订单状态是这套系统里最容易出 bug 的地方。很多毕设源码用String字段直接存“未支付”“已完成”,前端页面显示倒是简单,但后厨改一次文案,所有 if 判断跟着碎。我建议用整型状态码加常量类:

public class OrderStatus { public static final int PENDING_PAY = 0; public static final int PAID = 1; public static final int SERVED = 2; public static final int FINISHED = 3; public static final int CANCELED = -1; }

判断逻辑里写order.getStatus() == OrderStatus.PAID,前端拿到状态码再映射成“待支付”“制作中”“已出餐”。这样后端逻辑稳定,前端想改显示文案直接改映射表。状态流转也要收口:只有PENDING_PAY能点取消,只有PAID能置为SERVED,不能从FINISHED跳回PAID。这个项目的规模用 if 判断足够了,不必上状态模式。具体放在 Service 层处理:

public boolean cancelOrder(Long orderId) { Orders order = orderMapper.selectById(orderId); if (order == null || order.getStatus() != OrderStatus.PENDING_PAY) { throw new BusinessException("当前订单状态不可取消"); } order.setStatus(OrderStatus.CANCELED); return orderMapper.updateById(order) > 0; }

这段代码有两个关键点:先查再改,两个操作之间如果并发进来两次取消请求,理论上都会读到PENDING_PAY。对这个量级的项目,直接把状态更新写成UPDATE orders SET status = -1 WHERE id = ? AND status = 0,用数据库行锁兜底,比在应用层加锁简单可靠。要注意异常抛出后事务回滚,BusinessException上要标@Transactional(rollbackFor = Exception.class)

2.3 Spring Boot 后端:Controller 只管接收,业务放 Service

这套源码的三层结构比较标准:Controller 负责参数接收和结果包装,Service 负责事务和业务规则,Mapper 负责 SQL。一个创建订单的接口大致是这样:

@RestController @RequestMapping("/api/order") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping("/create") public Result<Long> create(@RequestBody OrderCreateDTO dto) { Long orderId = orderService.createOrder(dto); return Result.success(orderId); } }

Result<T>是统一返回体,包含codemessagedata三个字段。前端拿到code === 200才取data,否则弹错误信息。这里的@RequestBody要求前端传的 JSON 字段名与 DTO 属性名一致,比如userIditemsremark,不一致会直接反序列化失败,报HttpMessageNotReadableException

Service 里创建订单要干三件事:校验菜品是否存在且在售、计算总价并快照明细、一次事务里插入订单主表和明细表。重点说一下明细插入,很多人用 for 循环逐条 insert,小项目没问题,但如果菜品数量多,建议批量插入。MyBatis 的foreach拼一条多值 SQL,能少几次网络往返:

<insert id="insertBatch"> INSERT INTO order_detail (order_id, dish_id, dish_name, price, quantity) VALUES <foreach collection="list" item="detail" separator=","> (#{detail.orderId}, #{detail.dishId}, #{detail.dishName}, #{detail.price}, #{detail.quantity}) </foreach> </insert>

foreachcollection要写list,如果方法参数用@Param("details")注解,这里就要改成details。批量插入的 SQL 长度会随数据量增大,超过max_allowed_packet会报错,但点餐场景一次最多十几道菜,远达不到限制。

2.4 Mapper 写法:从注解到 XML 的边界

如果项目用的是 MyBatis-Plus,单表 CRUD 直接继承BaseMapper<T>就不用写 XML。但“查询最近订单”“按销量排序”这类带条件的语句,我推荐用注解方式写在 Mapper 接口里,可读性比 XML 好:

@Select("SELECT * FROM orders WHERE user_id = #{userId} ORDER BY create_time DESC LIMIT 5") List<Orders> selectRecentOrders(@Param("userId") Long userId);

#{}是预编译占位符,MyBatis 会把它转成?,防止 SQL 注入。${}是字符串拼接,只能用来拼接表名或者排序字段,而且前端传进来的值必须白名单校验。比如排序字段只允许create_timetotal_amount这两个值,否则直接返回参数错误。数据库表和索引设计起来不难,真正容易翻车的是状态字段的取值没人维护。把这套表结构和状态定义拿出来,单独也能当一份数据库课程设计交。

3. Vue 前端:点餐页、购物车与路由守卫

3.1 Vite 工程与依赖安装

Vue 端我用的是 Vite + Vue 3 + Pinia + Element Plus,这套组合也是目前 springboot + vue 项目最常见的搭档。拿到源码后先看package.json里 dependencies,再执行依赖安装:

npm install

如果安装慢,可以换淘宝镜像源:npm config set registry https://registry.npmmirror.com。这里要提醒的是,项目里的package-lock.json如果锁了旧版本依赖,在 Node 高版本下可能报ERR_PACKAGE_PATH_NOT_EXPORTED。我一般会删掉node_modules和锁文件重新装:rm -rf node_modules package-lock.json && npm install。Vue 安装依赖时最常见的坑是sassvite版本不匹配,报错会提示legacy-js-api,这种时候把sass降到 1.32.x 即可。

3.2 axios 封装:把鉴权和错误处理收口

前后端联调时,每个页面都写一遍axios.get会非常散。我通常先做一层封装:

import axios from 'axios' import { ElMessage } from 'element-plus' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error(error.response?.data?.message || error.message) return Promise.reject(error) } ) export default service

baseURL写成/api,开发环境靠 Vite 代理转发到后端,生产环境靠 Nginx 转发,前端代码里不出现具体 IP 和端口,避免换环境到处改。code !== 200时统一弹错误信息,业务代码里就不用每个接口都 try catch 一遍。这里有个细节:如果后端返回的data里还包了一层分页对象,前端拿到的类型是res.data.list,而不是res.data本身,接口分层时要把这个约定写清楚。

3.3 购物车用 Pinia:刷新不丢,跨页面共享

点餐页最核心的是购物车状态。用localStorage直接存选中的菜品也能用,但组件之间同步状态得靠事件,代码很快会臭。用 Pinia 管理更清晰:

import { defineStore } from 'pinia' export const useCartStore = defineStore('cart', { state: () => ({ items: [] }), getters: { totalCount: (state) => state.items.reduce((sum, i) => sum + i.quantity, 0), totalAmount: (state) => state.items.reduce((sum, i) => sum + i.price * i.quantity, 0) }, actions: { addItem(dish) { const existing = this.items.find(i => i.dishId === dish.id) if (existing) { existing.quantity++ } else { this.items.push({ dishId: dish.id, name: dish.name, price: dish.price, quantity: 1 }) } } } })

注意price要取后端返回的实时价格,不要取展示在页面上的格式化字符串。有些接口把价格序列化成"28.00"字符串,用String做加法就会变成"28.0028.00"。购物车状态需要持久化时,用 Pinia 的pinia-plugin-persistedstate插件把items同步到sessionStorage,比手写watch干净。为什么不用localStorage一直存着?因为用户关掉浏览器再打开,购物车还在会很奇怪,点餐场景会话结束就该重置。

3.4 点餐页联动与下单提交

菜品分类用侧边栏,右侧展示菜品列表,点击“加入购物车”按钮后右上角角标变化。这个联动逻辑就是categoryId变化时重新请求/api/dish/list?categoryId=xxx,拿到数据后渲染卡片。页面里最常见的低级错误是请求菜品列表时没有处理加载态,接口慢时用户连点两次,购物车里出现重复项。我的做法是点击后先 disable 按钮,等await cartStore.addItem(dish)完成后再恢复。

下单提交时,路由要带订单号跳转到详情页:

const submitOrder = async () => { const { data: orderId } = await api.post('/order/create', { userId: userStore.id, items: cartStore.items }) router.push({ name: 'OrderDetail', params: { id: orderId } }) }

这里的params传参刷新页面会丢,因为路由参数存在内存里。要保证刷新后还在,应当把订单 id 放到 query 上:router.push({ path: '/order/detail', query: { id: orderId } })。Vue 路由参数这个坑在点餐系统里尤其明显,因为用户很可能会在支付中途刷新页面。提交完订单要清空购物车,否则返回点餐页发现菜品还在,这是体验问题,也是答辩时容易被问到的细节。

4. 前后端联调:Vite 代理、Nginx 部署与报错排查

4.1 开发期跨域:Vite proxy 配置

Spring Boot 后端跑在 8080,Vue 跑在 5173,端口不同必然有跨域。前端接跨域最简单的不是在后端写@CrossOrigin,而是用 Vite 代理。修改vite.config.js

export default { server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }

这样页面里请求/api/order/list会被 Vite 转发到http://localhost:8080/api/order/listchangeOrigin: true会让后端拿到的请求头Host变成localhost:8080,避免某些框架根据 Host 做校验时报错。注意如果后端接口本身带了/api前缀,代理不需要 rewrite;如果后端接口是/order/list,前端请求/api/order/list,那就要加rewrite: (path) => path.replace(/^\/api/, '')。不改 rewrite 的后果是请求打到 8080 的/api/order/list,而后端 Controller 映射是/order/list,直接 404。

4.2 生产环境打包与 Nginx 反向代理

开发联调通过后,执行npm run build生成dist静态文件。这里要注意vite.config.js里的base配置。默认base/,如果部署在服务器子路径/order/下,必须设置base: '/order/',否则 js/css 资源全部 404,页面空白。部署到 Nginx 时,我一般这样配置:

server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

try_files $uri $uri/ /index.html;是必须的。因为 Vue Router 默认用 history 模式,用户直接访问/order/detail?id=123,Nginx 找不到这个真实文件,要把它重写到index.html,否则刷新就是 404。如果项目路由用的是 hash 模式,URL 长这样/#/order/detail,就不需要这个配置。我这个配置里/api/转发到后端时,路径会保留/api,所以后端接口必须带/api前缀,否则就把proxy_pass http://127.0.0.1:8080/;带斜杠来去除前缀。

4.3 常见报错:端口占用、数据库连接失败、刷新 404

这套源码在 window10/11 上跑,最容易栽的坑有三个。第一个是后端启动失败,报Port 8080 was already in use,说明端口被占了。在 cmd 里查占用进程:

netstat -ano | findstr 8080 taskkill /PID 1234 /F

第二个是数据库连接不上,Access denied for user 'root'@'localhost'。检查application.yml里的数据库名、用户名、密码是否和本地 MySQL 一致,特别是spring.datasource.url里的characterEncoding=utf8useSSL=false,少了后面那个在 MySQL 8 高版本会警告但不至于连不上。第三个是 Vue 打包后布局异常,图片裂掉、样式丢失,先看浏览器 Network 里 css/js 的路径,如果是绝对路径/assets/xxx.js但部署在子目录,就是base配置问题。这套排查顺序基本覆盖了我给 springboot 项目做联调时遇到的九成问题。

5. 答辩前把项目改成自己的:三个可验证的改动技巧

5.1 给菜品增加“销量”字段和排序

很多同类毕设的菜品列表都是按id或分类排,答辩时容易一眼看穿是模板。给dish表加一个sales字段,在下单事务里同步累加销量:

UPDATE dish SET sales = sales + 1 WHERE id = #{dishId}

然后菜品列表接口增加一个sortBy=hot参数,按销量倒序。前端在点餐页顶部加一个“按销量排序”的切换按钮。这个改动能让整个项目的业务逻辑多一个闭环,而且涉及数据库、后端、前端三层,是很好的加分项。

5.2 把订单状态数字替换成可读枚举

前端页面里如果到处都是order.status === 0,答辩时老师随机问“状态 2 是什么”会卡壳。在utils/orderStatus.js里定义映射:

export const ORDER_STATUS_MAP = { 0: { label: '待支付', type: 'warning' }, 1: { label: '制作中', type: 'primary' }, 2: { label: '已出餐', type: 'success' }, 3: { label: '已完成', type: 'info' }, '-1': { label: '已取消', type: 'danger' } }

页面里统一通过ORDER_STATUS_MAP[order.status]获取文案和 tag 类型,以后想改“制作中”为“备餐中”,只改一处。这个改动不涉及后端,但能让代码质量看起来高一个档次。

5.3 用 curl 写一份接口验证清单

答辩演示时现场点页面,最怕后端突然连不上。我习惯把核心接口验证写成脚本,答辩前跑一遍:

curl -s -X POST http://localhost:8080/api/order/create \ -H "Content-Type: application/json" \ -d '{"userId":1,"items":[{"dishId":1,"quantity":2}]}' | jq .

把上面命令保存为verify.sh,之后每次启动项目执行bash verify.sh,看到返回"code": 200就说明数据库、后端、接口链路都是通的。演示视频里的操作顺序也是这套:先启动 MySQL,再启动 Spring Boot,最后跑前端。按这个清单验证过再进答辩现场,能少很多突发状况。

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

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

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

立即咨询