SpringBoot+Vue网上点餐系统开发全解析:从数据库建模到部署实践
简介这是一份基于Spring Boot与Vue.js的网上点餐系统毕业设计项目源码包面向计算机相关专业、正在准备毕业设计或课程设计的学生。项目已经导师指导认可答辩评审分达97分并在Windows10/11环境下经过严格调试具备开箱即用的特点部署文档和演示视频能帮助使用者快速进入运行状态。压缩包整体约66.37MB包含前后端源码、数据库脚本、答辩PPT、毕业论文、使用文档及操作演示视频从系统开发到论文撰写再到答辩展示均有对应资料支撑整体结构清晰、配套内容完整能有效节省资料整理和排错时间。目前已有206人学习下载对于希望快速搭建网上点餐系统、积累Spring Boot与Vue.js项目经验的学生而言是一套完整度较高的参考方案尤其适合毕业设计、期末作业和答辩展示等场景。1. 别急着解压先把网上点餐系统当一套“完整开发流程”读拿到“基于SpringbootVue网上点餐系统源码数据库PPT论文使用文档演示视频高分项目.zip”这类压缩包时很多人的第一反应是解压、导入 IDEA、改个标题和 Logo、把演示视频里的话术背一遍然后直接放进毕业设计或简历作品集里。我见过不少这么操作的人答辩时被问“订单表和订单明细表为什么分开”“下单接口怎么保证不超卖”“Vue 路由刷新后为什么白屏”就卡住因为项目是别人的代码只在“能跑”的层面属于你。这个标题的真正价值不是那个高分结论而是它完整覆盖了一个业务系统的闭环Spring Boot 做后端接口与数据库交互Vue 做管理后台和用户端页面数据库里躺着菜品、订单、用户、购物车等一套可讲清楚的业务表PPT、论文和演示视频则是把“你会不会做”转译成“你能讲明白”的交付物。适合的人群很具体计算机相关专业正在做课程设计或毕业设计的学生想练全栈但不想从零搭脚手架的初级开发以及需要给团队快速搭一套内部点餐演示系统的工程师。接下来我从技术选型、数据库建模、核心接口实现、前端联调和最后的环境梳理五个层面把它讲成一份你自己也能复现的方案。2. Spring Boot Vue 选型逻辑与数据库建模先把表关系立住2.1 为什么是 Spring Boot 而不是 Spring Cloud为什么 Vue 2 Element-UI 更稳这是一个单人能在两周内做完、答辩时能讲清楚体量的小型业务系统Spring Boot 就是比 Spring Cloud 合适。Spring Boot 内置 Tomcatjava -jar 一条命令就能起服务不需要注册中心、配置中心和网关那套分布式基础设施。拿 Spring Cloud 来做点餐系统等于面试官问你“订单状态怎么流转”你先答了三分钟 Nacos 和 OpenFeign业务本身反而被稀释了。常见做法是 Spring Boot 2.x MyBatis-Plus MySQL Redis 的组合这套组合在校园项目和中小型内部系统中出现频率最高资料多遇到问题也好搜。前端这边Vue 2 Element-UI 仍然是这类项目包的主力搭配。Vue 3 的 Composition API 确实更现代但很多“高分项目”的原始代码基于 Vue 2 编写拿到的依赖、路由写法、组件库版本都是配套好的强行升级 Vue 3 会把this.$router、this.$refs这类 Options API 写法全部推翻殃及 Element-UI 的兼容性。我的建议是如果你想在简历上写 Vue 3可以早起一点但为了顺利答辩和稳定演示保持项目原始技术栈把精力放在业务逻辑和数据库设计上。2.2 核心表结构菜品、订单与订单明细为什么必须分离点餐系统的核心不在“登录注册”而在订单模型。菜品表很简单存菜品名、分类、价格、图片、销量和上下架状态但订单不能把菜品信息直接塞进一个字段里因为一个订单可能包含多个菜品每个菜品的数量可能不同而且菜品价格后续可能调整订单里必须保留下单那一刻的快照。经典做法是把订单拆成订单主表和订单明细表主表记录总金额、用户、状态、创建时间明细表记录每个菜品、数量、下单时单价和快照名称。先看建表 SQL 的核心部分CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 订单ID, order_no VARCHAR(32) NOT NULL COMMENT 订单号业务唯一, user_id BIGINT NOT NULL COMMENT 下单用户ID, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2制作中 3待取餐 4已完成 5已取消, address VARCHAR(255) DEFAULT NULL COMMENT 配送地址自提可空, remark VARCHAR(255) DEFAULT NULL COMMENT 备注, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表; CREATE TABLE order_items ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL COMMENT 订单主表ID, dish_id BIGINT NOT NULL COMMENT 菜品ID, dish_name VARCHAR(50) NOT NULL COMMENT 菜品快照名称, dish_price DECIMAL(10,2) NOT NULL COMMENT 菜品下单时单价快照, quantity INT NOT NULL COMMENT 购买数量, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;order_no用业务唯一键而不是直接拿自增 ID 对外是为了后续对接支付和查询时有个不可猜测的凭证order_items里的dish_name和dish_price是快照字段菜品改名或调价后历史订单的数据依然真实。这个设计是答辩时非常加分的点踩过订单系统坑的人都知道没有快照的订单表是残缺的。建议图纸上把这两张表画成一对多关系并把“为什么要两张表”这句话写在论文的数据库设计章节里。2.3 用 SQL 把库初始化出来并在 application.yml 里接上拿到源码包后数据库不是自动生成的需要手动执行 SQL 脚本。常见做法是将db目录下的初始化脚本导入 Navicat 或命令行。先用 Navicat 新建数据库字符集选 utf8mb4排序规则选 utf8mb4_general_ci然后右键运行 SQL 文件。执行成功后检查表数量一般包含 user、dish、category、orders、order_items、cart、address 等表如果缺表请检查是否执行了第二个增量更新脚本。后端连接配置在src/main/resources/application.yml里核心配置如下server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/online_order?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: yourpassword redis: host: localhost port: 6379 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImplserverTimezoneAsia/Shanghai和GMT8这两个时间配置必须同时存在否则插入订单时间会比本地时间差 8 小时这是最常见的数据库时区坑。log-impl配置成 StdOutImpl 后控制台会打印每条 SQL联调时用这个功能看 MyBatis-Plus 实际生成的语句非常方便上线前再关掉。3. Spring Boot 后端落地登录、权限、缓存与订单事务3.1 JWT 登录与拦截器放行路径列表是第一个坑网上点餐系统涉及用户端和管理员端通常用 JWT 做无状态登录。用户登录成功后后端返回一个token前端存在 localStorage 里每次请求放到 Header 的Authorization字段。拦截器里要判断 token 是否有效同时必须维护一个“放行路径”列表否则登录页本身都进不去。Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/**) .excludePathPatterns( /api/user/login, /api/user/register, /api/dish/list, // 菜品浏览不登录 /api/category/list, // 分类列表不登录 /static/**, // 静态资源 /error ); } }参数说明/**表示拦截所有请求excludePathPatterns里的路径按实际接口命名调整。菜品列表和分类列表放行是因为用户点餐前先看到菜单是基本体验但加入购物车和提交订单的接口必须拦截。拦截器里解析 token 后把 userId 放到request.setAttribute(userId, id)Controller 里再从HttpServletRequest拿不要在拦截器里直接去调数据库查用户那样每一步请求都多一次 IO。3.2 菜品分类用 Redis 缓存缓存穿透与 key 设计菜品和分类是高频读、低频写的数据适合放进 Redis。每次用户打开菜单都查一次 MySQL 没问题但演示时反复开关页面会有大量重复查询加上缓存后响应速度能快一个量级。常见做法是查询时先读 Redis没有则查库并回填缓存过期时间设为 30 分钟。public ListDish listByCategory(Long categoryId) { String key dish:category: categoryId; String json redisTemplate.opsForValue().get(key); if (StringUtils.isNotEmpty(json)) { return JSON.parseArray(json, Dish.class); } ListDish dishes this.lambdaQuery() .eq(Dish::getCategoryId, categoryId) .eq(Dish::getStatus, 1) .orderByDesc(Dish::getSales) .list(); if (dishes null || dishes.isEmpty()) { return dishes; } redisTemplate.opsForValue().set(key, JSON.toJSONString(dishes), 30, TimeUnit.MINUTES); return dishes; }这个写法有三个细节一是 key 要带业务前缀dish:category:避免和其他缓存互相覆盖二是空结果不回填如果分类下确实没有菜品每次都查库也能接受比把空值缓存起来更容易踩空壳问题三是timeout设 30 分钟菜品上新或价格变动后最多延迟半小时生效。管理员在后台修改菜品时必须同步调用redisTemplate.delete(key)清理对应缓存否则会出现前端改了价格、App 上还是原价的“菜单幽灵”问题。3.3 下单接口的事务边界与幂等处理下单是点餐系统最核心的接口流程包含校验菜品状态和库存、计算总金额、生成订单主表、生成订单明细、扣减菜品销量或库存、清空用户购物车。整个过程必须在一个事务里任何一步失败都要回滚不能让订单明细写了但主表没写。Transactional(rollbackFor Exception.class) public OrderVO createOrder(CreateOrderRequest request, Long userId) { // 1. 校验购物车是否为空 ListCart cartList cartMapper.selectList( new LambdaQueryWrapperCart().eq(Cart::getUserId, userId)); if (cartList null || cartList.isEmpty()) { throw new BizException(购物车不能为空); } // 2. 生成订单号时间戳 用户ID后四位 随机数 String orderNo generateOrderNo(userId); // 3. 计算总金额并构建主表 BigDecimal total calculateTotal(cartList); Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(total); order.setStatus(0); orderMapper.insert(order); // 4. 明细表批量插入 ListOrderItems items buildOrderItems(cartList, order.getId()); orderItemMapper.batchInsert(items); // 5. 清空购物车 cartMapper.delete( new LambdaQueryWrapperCart().eq(Cart::getUserId, userId)); return new OrderVO(order); }参数说明rollbackFor Exception.class必须写默认的Transactional只回滚运行时异常自定义的BizException如果不继承 RuntimeException事务不会生效。订单号生成规则里加随机数的原因是防止同一秒内同一用户下单时产生重复号把订单号生成逻辑单独抽成方法后续做幂等校验时直接复用。订单号的幂等还有一个常见做法是把“用户ID 时间窗口”作为唯一约束的组成部分。如果你不想在代码里费太多功夫可以在orders表加一个user_id和create_time的联合索引配合前端“提交按钮点击后置灰 3 秒”来控制重复点击这样虽然不能完全防止并发但对课程设计的演示场景足够。4. Vue 前端落地依赖安装、路由传参与请求拦截4.1 创建 Vue 项目与依赖安装先把 node_modules 的版本冲突清掉标题里的 Vue 部分操作上最容易劝退的不是写页面而是npm install装依赖那一步。很多“高分项目”用的 Element-UI 版本、vue-router版本和node-sass是绑定在一起的直接npm install经常报 ERESOLVE 错误。npm install --legacy-peer-deps这个参数表示绕过 peerDependencies 的严格校验用旧版的依赖解析逻辑。node-sass是最容易编不过的包如果你的 Node 版本是 16 以上通常需要把node-sass换成sass才能顺利安装安装完成后项目里import的语法要检查一遍。如果启动时提示Module build failed先删掉node_modules和package-lock.json重新npm cache clean --force后再安装这一步能解决绝大多数依赖损坏问题。4.2 动态路由与路由参数页面刷新后 userInfo 为什么丢了前端项目结构一般有两种做法把用户端和管理员端放进同一个 Vue 项目通过路由做区分或者拆成两个独立工程。常见做法是用一个工程包含两套 Layout用角色字段跳转不同首页。路由传参最容易踩的坑是点餐页跳到商品详情页时用了this.$router.push({ path: /detail, query: { dId: id } })详情页从this.$route.query.dId取参数这个链条没问题但如果你在详情页里刷新浏览器localStorage里的 token 还在userInfo却因为存在storeVuex/Pinia里而丢失页面直接 404 或跳回登录页。解决办法是刷新后重新拉取用户信息。在路由全局守卫里判断有 token 但 store 里没有 userInfo 时调用/api/user/info重新获取。router.beforeEach(async (to, from, next) { const token localStorage.getItem(token); if (token !store.state.userInfo) { try { const { data } await getUserInfo(); store.commit(setUserInfo, data); next(); } catch (error) { localStorage.removeItem(token); next(/login); } } else { next(); } });同时注意dishId这类业务参数建议放进query里而不是params因为query会出现在 URL 上刷新后依然存在params在刷新后会丢失这是“页面刷新白屏”的另一个隐性来源。4.3 axios 封装统一处理 token 失效与业务状态码整个点餐系统的所有接口请求应该走一个统一的 axios 实例而不是在每个组件里写axios.get。统一的请求封装里要处理三件事请求头带上 token、响应里统一判断业务状态码、出现 401 时统一跳转登录页。import axios from axios; import { ElMessage } from element-ui; import router from /router; const service axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截器 service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截器 service.interceptors.response.use( response { const res response.data; if (res.code ! 200) { ElMessage.error(res.msg || 系统错误); return Promise.reject(new Error(res.msg)); } return res; }, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } ElMessage.error(error.message); return Promise.reject(error); } );参数说明baseURL设为/api开发环境由 vite.config 或 vue.config 里的 devServer proxy 把请求转发到后端 8080 端口可以避开跨域正式环境则用 Nginx 反向代理。后端返回格式统一为{ code, msg, data }是这套方案的前提如果源码包里的响应结构是{ success, message, result }对应改拦截器里的字段名即可。4.4 购物车与菜品列表的双向联动computed 比 watch 更稳购物车数量和总价是实时联动的初学者常写成watch监听购物车数组变化然后手动更新总价代码量大且容易遗漏。推荐用 Vue 2 的computed计算属性直接从购物车 state 派生总价不需要额外维护数据。computed: { cartList() { return this.$store.state.cartList; }, totalPrice() { return this.cartList.reduce( (sum, item) sum item.price * item.quantity, 0 ).toFixed(2); } }reduce里累加的是每个商品的单价乘数量toFixed(2)保证价格显示成两位小数。这里要提醒的是不要用watch去改data否则控制台会出现 “Computed property totalPrice was assigned to but it has no setter” 这类警告。购物车放在 Vuex 里还有一个好处是刷新页面后虽然 state 丢失但可以结合localStorage做持久化在store初始化时读取缓存让用户刷新后购物车不清空这个细节能直接写进论文的“系统优化”章节。5. 订单状态机与并发扣库存点餐系统最容易暴露的问题点5.1 订单状态流转从待支付到已完成后端要用枚举收口订单状态如果在前端写死字符串后端接口直接传字符串改状态项目跑起来没问题但答辩时一旦被问到“状态值在哪定义的”就会暴露设计薄弱。合理的做法是在后端定义一个枚举类把状态收口接口只接收枚举对应值而不是任意字符串。public enum OrderStatus { PENDING_PAYMENT(0, 待支付), PAID(1, 已支付), PROCESSING(2, 制作中), READY(3, 待取餐), COMPLETED(4, 已完成), CANCELLED(5, 已取消); private final int value; private final String desc; OrderStatus(int value, String desc) { this.value value; this.desc desc; } public int getValue() { return value; } public String getDesc() { return desc; } }状态流转的校验逻辑放在updateStatus方法里待支付可以取消或支付已支付才能进入制作中已完成和已取消是终态不能往回走。这个校验逻辑写在 service 层每个改状态的接口都先读订单再判断当前状态是否允许变更比在数据库里写触发器更容易读和维护。演示时如果被问“用户端取消订单后座位或菜品库存怎么处理”你回答说“取消逻辑里先把订单状态置为已取消再回补菜品销量”这就是一个完整的闭环回答。5.2 并发下单超卖数据库行锁比 Redis 分布式锁更合适点餐系统的菜品库存字段通常叫stock或sales。单纯先select再update的方式在两个人同时下单时会超卖两个人同时查到库存为 1各自经过业务判断后都执行扣减库存变成负数。常见做法有两种一是 Redis 分布式锁二是数据库乐观锁或悲观锁。对这个体量的项目数据库悲观锁足够而且最好解释。SELECT stock FROM dish WHERE id ? FOR UPDATE;FOR UPDATE会锁住这一行直到事务提交或回滚。第二个请求必须等第一个事务完成后才能读库存所以不会出现同时读到相同库存的竞态。注意这个查询必须在事务里执行并且走主键索引才能拿到行锁如果查询条件不是索引字段InnoDB 会把锁升级成表锁拖慢整张菜品表。对应 Java 代码里就是先selectByIdForUpdate判断库存大于 0 后执行UPDATE dish SET stock stock - 1 WHERE id ?。还有一种更轻的做法是在 update 语句里带条件查库存UPDATE dish SET stock stock - 1 WHERE id ? AND stock 0;如果返回的受影响行数为 1说明扣减成功为 0 说明库存不足。这个方案不需要额外加锁代码量更少缺点是库存充足时并发下没问题但无法预知实际剩余库存先扣减后才发现不足就得回滚。推荐的做法是两条路一起走stock 0作为兜底条件前置查询加FOR UPDATE保证业务提示更友好。5.3 支付回调与本地模拟用一个 mock 接口闭环联调真实支付需要商户号、证书等资质课程设计里往往没有。常见做法是保留前端的“立即支付”按钮后端接一个 mock 支付接口按下后把订单状态从待支付改为已支付再走后面的制作中流程。PostMapping(/api/pay/mock) public ResultVoid mockPay(RequestParam String orderNo, RequestParam Long userId) { Orders order orderMapper.selectOne( new LambdaQueryWrapperOrders().eq(Orders::getOrderNo, orderNo)); if (order null || !order.getUserId().equals(userId)) { throw new BizException(订单不存在); } if (order.getStatus() ! OrderStatus.PENDING_PAYMENT.getValue()) { throw new BizException(订单状态不正确); } order.setStatus(OrderStatus.PAID.getValue()); orderMapper.updateById(order); return Result.success(); }真实支付的回调接口写的是“被动通知”由支付平台主动请求后端接口前端轮询订单状态mock 支付则是前端发起请求后立即改状态。为了让演示更真实可以加一个“模拟支付延迟”的Thread.sleep(1500)参数让用户先看到待支付状态再变已支付演示视频里这个过渡比瞬间变更好看。另外一个加分项是本地模拟“支付回调幂等”如果前端重复提交两次 mock 请求第二次要返回“订单状态不正确”而不是把已支付订单再改一遍这个防御逻辑证明你理解状态机的不可逆性。5.4 图片上传本地路径与 OSS 的切换方案菜品图片上传是这种项目最常见的管理员功能。简单方案是把图片存到本机项目目录下通过配置静态资源映射来访问复杂一点是接阿里云 OSS。本地方案虽然不具备生产可用性但演示时最不容易挂。spring: web: resources: static-locations: file:D:/upload/,classpath:/static/上传代码将MultipartFile保存到D:/upload目录文件名用UUID重命名以覆盖同名冲突。注意两点一是保存路径和访问 URL 要能对应如果上传到D:/upload/而请求路径是/images/xxx.jpg要再加一个映射关系或直接用http://localhost:8080/xxx.jpg拼二是 Windows 和 Linux 路径分隔符不同建议在配置文件里把upload.path单独抽出来换环境只改一个配置。对接 OSS 的时机应当放在论文的“系统改进方向”里既能体现你了解对象存储又不影响当前演示环境。6. 跑通后的三件收尾事环境编排、排错技巧与“能讲出话”的文档6.1 用 Docker Compose 一键带起 MySQL 与 Redis本地开发时 MySQL 和 Redis 未安装或版本混乱会导致项目起不来。推荐用 Docker Compose 把两个中间件一次性带起来环境干净可复现。version: 3 services: mysql: image: mysql:5.7 container_name: online-order-mysql ports: - 3306:3306 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: online_order command: --character-set-serverutf8mb4 --collation-serverutf8mb4_general_ci volumes: - ./mysql-data:/var/lib/mysql - ./db:/docker-entrypoint-initdb.d redis: image: redis:6.2 container_name: online-order-redis ports: - 6379:6379./db:/docker-entrypoint-initdb.d这个卷挂载会使 MySQL 容器第一次启动时自动执行目录里的.sql脚本免去手动导入。注意 MySQL 8.0 的认证方式与很多旧驱动不兼容项目驱动连接不上时先检查容器版本。Redis 容器没有设置密码application.yml里也不用配密码保持双端一致。6.2 常见环境排错Spring Boot 版本、MAVEN 依赖与 Node 构建“springboot 版本太高”是绕不开的坑。如果你在 IDEA 里用 Spring Initializr 新建项目最新版本可能是 Spring Boot 3.x它用的是jakarta.*命名空间而课程设计项目基于 Spring Boot 2.x包名依然是javax.*直接把源码复制进新项目会编译失败提示找不到javax.servlet.http.HttpServletRequest。正确做法是先看pom.xml里 parent 的版本2.x 项目就选 2.7.x 的 Spring Boot不要顺手选最高版本。Maven 依赖拉不下来时先确认settings.xml里用的镜像源国内环境建议使用阿里云镜像配置方式是在镜像mirrorOf里写central。IDEA 打开项目后要等依赖索引完成再启动否则会出现ClassNotFoundException但这只是 IDE 编译缓存问题敲一遍mvn clean install即可验证。前端构建环节npm run build打包后布局异常通常有两种原因一是打包后的静态资源路径不对需要检查vue.config.js里的publicPath改成./相对路径后本地打开 index.html 才能看到页面二是历史模式刷新 404这是vue-router的mode: history与静态服务器不匹配导致的hash模式不会有这个问题想用 Nginx 部署就配置try_files $uri $uri/ /index.html;想省事就直接退回 hash 模式。6.3 给项目做点“证据”接口清单、数据库文档与演示脚本答辩和简历里最能证明你做过这个项目的不是 PPT 里的截图而是可核查的交付物。第一个是接口清单把 Spring Boot 里所有 Controller 方法导出成一张表包含请求方式、路径、入参、出参、是否登录、是否管理员权限。最简单的方式是用 Springfox/knife4j 生成在线文档启动项目后访问/doc.html就能查看省去手写时间。第二个是数据库文档把information_schema里的表结构导出成 Excel或者用SHOW CREATE TABLE逐个拉出来至少要准备一页说明各表用途、关键字段含义和表关系放进论文附录比放进代码更有说服力。第三个是演示脚本按“管理员上架菜品→用户注册登录→浏览菜单→加购→下单→模拟支付→订单状态流转→后台接单完成”这个顺序把每个操作对应的页面和接口写清楚演示视频按这个脚本录制不会出现中途卡顿或忘词的情况。这三个东西做完你就着这个压缩包里的源文件能脱口说出“MySQL 五张核心表、JWT 没状态会话、Redis 缓存菜单分类、悲观锁防超卖、Vue 路由守卫刷新恢复登录态”——顺着这个结构讲面试官追问任何一层你都还有继续展开的余量。跑通只是起点把技术点翻译成自己的话才是这一步的终点。本文还有配套的精品资源点击获取