行业资讯
📅 2026/9/9 20:43:55
外卖点餐管理系统实战:Spring Boot + Vue 前后端分离设计与部署全解析
最近做了一套基于 Spring Boot Vue 的外卖点餐管理系统从数据库设计、后端接口、前端页面到打包装部署走了一整遍中间踩了不少坑也总结出一些自己的经验。这套项目的技术栈是很经典的“Spring Boot 做后端接口 Vue 做前端页面”组合前后端分离几乎所有计算机专业毕业设计里都能看到它的影子。这篇文章就把我做这个项目时怎么拆需求、怎么设计表、怎么写接口、怎么调页面、怎么部署的过程完整梳理一遍你手头如果不确定外卖系统该从哪儿下手或者正在为毕设项目发愁可以参考一下。先说清楚一件事这套系统不是一个放上去凑数的空壳它有完整的功能闭环——用户在小程序或网页端看菜品、加购物车、下订单商家在后台接单、出餐、配送管理员在平台做审核和数据统计。每个角色都有真正能用的功能不是那种“只有登录注册”的应付作业。我会把核心表结构、后端逻辑、前端交互以及上线部署都拆开讲尤其是“为什么这么设计”的部分写文档和答辩的时候特别有用。1. 项目整体设计与技术选型思路1.1 这套外卖系统到底做什么我把外卖业务拆成三个核心角色用户、商家、管理员。用户端要做的事很直观——注册登录、浏览附近的店铺、查看菜品、将菜品加入购物车、提交订单、在线支付毕设一般走模拟支付、查看订单状态、最后还可以评价商家端则要维护菜品和分类信息管理订单状态接单、拒单、发货、完成最好还有一个粗略的营业统计管理员端负责整体平台运营包括审核商家入驻、管理用户状态、查看平台总营收和订单量。很多人做毕业设计的时候习惯“拿到题目就开始建表写代码”结果做到一半发现功能根本串不起来。我建议反过来先把角色和场景画出来。比如用户下单这个动作涉及购物车数据写入、生成订单、生成订单明细、扣减库存、商家收到推送提醒、用户看到待接单状态一串下来才算完整的业务闭环。我在做之前用文字把三个角色的核心流程梳理了一遍写清楚每一个角色的“任务清单”后面所有表和接口都围绕这个清单展开这样做基本不会跑偏。1.2 为什么选择Spring Boot Vue而不是其他方案市面上有很多组合可选比如如果求快可以直接用若依框架Spring Boot Thymeleaf 也可以做甚至 JSP 技术也能实现但我建议把项目做成前后端分离主要原因有三点。第一Spring Boot 作为后端开发框架确实省心。它内置了 Tomcat不用单独配置复杂的 XML引入相关依赖后写好 Controller、Service、Mapper 就能提供 RESTful 接口。加上 MyBatis 或 MyBatis-Plus 做数据层操作连 SQL 都能自动生成大部分开发速度能提升不少。第二Vue 做前端把“数据和页面分离开”这是传统模板引擎很难做到的。点餐页面中购物车角标、菜品列表、订单状态的联动非常多Vue 的双向绑定和组件化开发让这部分代码很清晰。比如菜品数量加减时购物车总价实时变化Vue 里只需要维护一个 reactive 的 cart 数据对象页面自动刷新不用手动操作 DOM。第三前后端分离的协作模式更贴近真实企业开发。后端专注输出 JSON 格式数据前端专注页面展示和交互两边通过接口文档约定字段有问题单独调试不用像 JSP 那样静态资源和 Java 代码耦合在一起。如果你是赶时间的毕业生这里我先给一个建议这个技术栈不要临时换尤其是已经确定了题目的情况下。Spring Boot Vue 的资料最全遇到问题网上一搜就有解决方案换成冷门框架反而容易卡死。1.3 模块划分和代码结构后端我用的是经典的三层架构Controller 层负责接收 HTTP 请求和参数校验Service 层处理业务逻辑Mapper 层DAO 层访问数据库。不要把所有代码都塞在 Controller 里虽然那样看起来很“高效”但项目一旦到了上千行代码维护就是你自己的噩梦。Controller 只负责“接参数、调服务、返回包装类”核心规则写到 Service这样答辩的时候老师问你“业务逻辑在哪里”你可以很清晰地说出来。前端结构上我按“页面视图 API 调用 组件复用”来组织。src/api 放 axios 请求文件src/views 放页面src/components 放公共组件。比如菜品卡片、分类导航、订单状态标签这些在多个页面出现抽成组件之后改一处全局生效。2. 数据库设计与业务表结构拆解2.1 核心表清单与角色关系我设计的时候一共建了9张核心表分别是用户表、商家表、菜品分类表、菜品表、购物车表、订单主表、订单明细表、收货地址表、评价表。这套结构基本覆盖了外卖业务的通用模型哪怕后面要加会员、加优惠券也能在这上面扩展。表名作用关键关联user平台用户购物车、订单、地址、评价shop商家店铺菜品、订单category菜品分类店铺下的分组dish菜品购物车、订单明细cart购物车用户 菜品orders订单主表用户 商家 地址order_detail订单明细订单下的快照address收货地址用户comment评价用户 订单 店铺/菜品2.2 订单相关表的设计一个容易掉坑的地方外卖系统里最重要的表就是订单相关这部分很有代表性。我在设计订单主表和明细表时特别强调一个细节明细表里要冗余“菜品名称”和“菜品价格”字段而不是直接去关联菜品表。原因很简单菜品的价格会变动今天这个菜卖 18 元明天可能因为食材涨价变成 20 元。用户昨天下的订单金额已经固定是 18 元如果订单明细直接关联菜品表那今天看昨天的订单价格会自动变成 20 元这是绝对不允许的。所以订单明细表里的 dish_name、dish_price、dish_image 都是冗余字段相当于下订单那一刻给商品拍了一张“快照”。这和电商系统里订单快照的逻辑一样背后核心思想是“历史记录不可变”。订单表还有一个容易忽略的地方就是总金额字段 total_amount 也要单独存。很多毕设只存了明细金额然后临时加总这会带来两个问题一是如果用户在提交订单前商家修改了菜品价格你重新加总的结果可能跟用户当时看到的不一致二是后期统计报表时每次都去 join 明细聚合计算非常消耗数据库性能。2.3 状态字段与金额类型的选择订单表里我用了 status 字段控制订单生命周期待付款、待接单、待配送、配送中、已完成、已取消、退款中。一开始我甚至想用字符串存状态名比如 status 待接单后来发现这是非常致命的坑。字符串状态没法统一管理而且每次判断都要用字符串匹配效率低、容易写错。改成数字枚举后只要在前后端各维护一份状态映射字典就好了。数据库存 0、1、2、3 这种数字前端根据数字展示对应的状态标签。这里我的习惯是“后端存数字前端做映射”这样接口传输数据量小数据库索引效率也高。金额字段必须用 decimal 而不是 float 或 double这一点如果不注意很影响数据准确性。外卖系统涉及钱float 存在二进制精度问题0.1 0.2 算出来不一定是 0.3。我用的是 decimal(10, 2)10 位总长度、2 位小数能覆盖到百万级别的金额对毕设项目完全够用也符合财务类字段的常规做法。3. 后端核心模块实现从登录鉴权到订单流转3.1 用户登录和JWT鉴权是怎么做出来的外卖系统的登录不能像传统单体应用那样用 Session因为前后端分离后前端可能部署在一台服务器后端部署在另一台服务器Session 在多实例环境下也不好共享。我用的是 JWTJSON Web Token方案这是当前企业项目最常用的做法之一。逻辑是这样用户提交用户名和密码后后端校验通过生成一个 token 返回给前端。token 里面封装了用户 ID、用户名和过期时间再用密钥签名防止被篡改。前端拿到 token 后存到 localStorage之后每次发请求时在请求头里加上 Authorization: Bearer token。后端写一个拦截器或过滤器专门校验请求头中的 token合法就放行不合法就返回 401。后端伪代码大致是这样PostMapping(/login) public Result login(RequestBody LoginDTO dto) { User user userService.login(dto.getUsername(), dto.getPassword()); String token JwtUtil.createToken(user.getId(), user.getUsername()); return Result.ok(new LoginVO(user.getId(), user.getUsername(), token)); }拦截器里我实现了一个 HandlerInterceptor核心方法是 preHandle它的任务就是取 token、解析 token、把用户信息放进去。要注意的是不是所有接口都需要拦截。登录注册这些接口肯定要放行菜品浏览这种公开数据也可以放行只有涉及个人信息的接口才需要鉴权。我在配置类里做了白名单用 excludePathPatterns 统一设置比在每个接口上手动加注解要清晰得多。还有一个真实场景需要注意用户下单时后端必须知道当前登录的用户是谁不能相信前端传过来的 userId否则容易被伪造。我的做法是拦截器里解析出 token 中的用户 ID把它存入 ThreadLocal 或直接放到 request attribute 中然后在 Controller 里读取。这样就能保证“只能操作自己的数据和订单”这一点在答辩时可以重点讲。3.2 下单逻辑事务和库存扣减下订单是整个系统的核心接口也是后端代码里最有技术含量的地方之一。整体流程分这么几块校验地址、计算购物车中的有效菜品、生成订单主表记录、生成订单明细记录、清空购物车、扣减菜品库存。这些操作必须是一个原子操作哪个环节失败都要回滚否则可能出现“订单生成了但购物车没清空”或者“库存扣了但订单没创建成功”这类数据不一致的情况。Spring Boot 处理这个场景很方便在 Service 方法上加上 Transactional 注解就能开启事务方法里任何一步抛出 RuntimeException前面已经执行的数据库操作都会回滚。我这里想特别强调一个小细节事务要加在 Service 层的具体业务方法上而不是 Controller 方法上。如果你把事务注解误加到 Controller 层事务可能不会生效因为 Spring 的声明式事务是基于 AOP 的默认只对 Service 层的方法做代理。库存扣减这里如果对并发有要求我不会直接使用“先查库存判断够不够再扣减”这种方式因为在高并发场景下两个请求同时查到库存还剩 1 件判断都通过了然后都执行扣减结果库存变成 -1。但毕设阶段通常不会遇到这么大的并发量所以不需要引入分布式锁或 Redis 预扣库存我只要用一条带条件的 update SQL 就能解决update dish set stock stock - 1 where id #{dishId} and stock 0如果返回的影响行数为 0说明库存不够直接抛出业务异常。这种写法一行 SQL 同时完成“判断”和“扣减”既简单又不会超卖放在答辩里也有亮点。3.3 商家端接单和状态流转商家端核心是订单管理包括查看新订单、接单、拒单、完成订单。我在订单表上加了 shop_id商家登录后只能查询自己店铺的订单。这里也体现了一个模块设计思路把公共逻辑抽取出来比如一个 BaseController 里放获取当前用户信息的方法因为后端不只有用户端商家端、管理员端也都需要拿到当前登录人。状态流转需要做合法性校验不允许从“待付款”直接跳到“已完成”中间必须经过“待接单”“配送中”这些环节。我实现了一个状态机校验的方法每次更新状态时先判断前置状态不合法就提示“订单状态异常”。虽然有点繁琐但能有效防止接口被恶意调用后乱改数据。配送这一块外卖系统可以有两种选择自己配送或者接入第三方配送。毕设阶段接第三方平台成本和复杂度都太高自己配送则需要在订单上记录配送地址和联系方式。我把地址做成了快照也就是下单时把用户选中的地址信息复制一份到订单表里。这跟订单明细存快照是同一个思路避免用户后来修改地址影响历史订单。4. 前端Vue端到端实现要点4.1 从零搭建Vue项目与路由权限前端我用的 Vue 3 Vite 构建相对于 Vue 2 Webpack启动速度快很多而且组合式 API 写起来更舒服。创建项目的命令很简单npm create vuelatest选好 TypeScript 和 Vue Router 后项目骨架就出来了。我的原则是不要为了用 TypeScript 而用 TypeScript如果你对类型不熟毕设完全可以选 JavaScript 模式不然光类型报错就能卡你一天。路由设计上我把用户端页面分成几类首页、菜品详情、购物车、订单列表、订单详情、个人中心、登录注册商家端单独一套路由。两套页面用一句话总结就是“同一后端不同前端”。用户端和商家端虽然都是 Vue 项目但页面差异很大我分别建了两个入口也可以做成一个入口用角色路由区分这个看个人习惯。路由守卫是我一定要加的部分。用户没登录就想进“我的订单”页面必须跳转登录页。我的实现是在 router.beforeEach 里判断 to.meta.requiresAuth 和本地 token同时还要校验当前用户角色是否匹配页面要求。比如商家后台页面必须 role 是 2 才能访问管理员页面必须 role 是 1。这样不仅是一种常见的项目亮点也能保障各个角色的数据安全。4.2 统一封装Axios请求一个关键环节如果不做封装Vue 页面里到处都会出现 axios.get 这样零散的代码而且每个请求都要手动加 token每次请求失败都要自己弹提示代码冗余度非常高。我封装了一个 request.js统一做三件事。第一请求拦截器里自动把 token 加到请求头这样业务代码里就不用每次都写 headers第二响应拦截器里统一处理 HTTP 错误码比如 401 代表 token 过期自动跳转登录页第三后端返回的 JSON 里有自己的业务状态码比如 code 为 200 是成功500 是失败我在拦截器里判断状态码统一用 Element Plus 的 message 组件弹出错误提示。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) { router.push(/login) } return Promise.reject(error) } )封装好后每个页面调用接口只需要一行代码非常清爽。比如获取菜品列表const res await getDishList({ categoryId: currentId })4.3 点餐核心场景购物车和订单状态的前端交互购物车是整个点餐页面里交互最复杂的部分。用户点击加号、减号左侧列表数量变化右上角购物车角标变化底部总价实时刷新。用 Vue 的 reactive 对象维护一个购物车 Mapkey 是 dishIdvalue 是数量和菜品信息。加菜时判断是否已存在存在则数量加一不存在则新增减菜时数量减到 0 就删除条目。所有页面绑定这个响应式数据状态自动联动一点都不用手动刷新视图。订单页展示时前端要根据后端返回的 status 字段渲染对应标签。我在 utils/dict.js 里放了一份映射表比如 0 表示待付款、1 表示待接单、2 表示配送中页面里直接用自定义过滤器或计算属性转换成文字。这里要注意后端不要返回一个“显示用”的字符串也不要返回一个“逻辑用”的数字之后完全不管展示前后端的字典映射必须保持一致否则就会出现状态错乱。前端还有一个小细节值得提图片加载失败时要用一张默认占位图替代否则页面会出现一个破碎的图片图标。我用了一个全局指令 v-imgFallback给 img 标签绑定 onerror 事件切换成默认地址。这种小改进虽然不起眼但很影响最终演示时的整体观感答辩时老师看到你注意到这些细节是很加分的。5. 前后端联调、打包部署与常见坑5.1 跨域问题本地联调中最常碰到的报错本地开发时前端地址是 localhost:5173后端是 localhost:8080两个端口不一样浏览器就会默认拦截跨域请求。最常见的报错就是Access to XMLHttpRequest at http://localhost:8080/api/... from origin http://localhost:5173 has been blocked by CORS policy。解决跨域的办法有很多我可以三个方向都提一下。最简单的做法是在后端写一个配置类允许所有来源访问Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .maxAge(3600); } }更优雅的做法是利用 Vite 的 proxy 代理把 /api 开头的请求转发到后端地址。这样前端代码里写的是 /api/login浏览器请求的是同源的 5173 端口而 Vite 在服务端把请求转发到了 8080完全避开了跨域问题。生产环境部署时再用 Nginx 反向代理统一处理效果最好。这里我提醒一个点如果同时使用了拦截器做 JWT 校验那么跨域请求会有一个 OPTIONS 预检请求这个预检请求是不带业务数据的如果你的拦截器拦截了 OPTIONS 请求并校验 token会导致前端控制台报错。解决方案是拦截器里直接放行 OPTIONS 请求否则调接口时很诡异明明感觉逻辑没问题就是不通过。5.2 数据库时区错误和连接配置Spring Boot 连接 MySQL 时我刚开始遇到过报错The server time zone value Öйú±ê׼ʱ¼ä is unrecognized or represents more than one time zone.原因是 MySQL 驱动 8.0 以后强制要求指定时区而服务器默认时区是 CST 会有歧义。解决办法是在连接串后面加上 serverTimezoneAsia/Shanghai同时关闭 SSL 警告spring: datasource: url: jdbc:mysql://localhost:3306/food_delivery?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf-8这里也有一个很多人忽略的坑characterEncoding 如果不指定为 utf-8数据库里插入中文时会被转成问号或乱码。如果发现数据库里的中文是乱码不要只去改代码还要确认数据库本身的字符集是不是 utf8mb4尤其是 MySQL 8.0 的默认字符集情况。我在建库时专门执行了CREATE DATABASE food_delivery DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;这样中文、表情符号都能正常存储。外卖系统用户评价里很可能有人输入 emoji如果字符集不是 utf8mb4这个字段会保存失败到时候排查起来很费劲。5.3 打包部署从本机到服务器前后端分离项目部署有两个关键产物后端的 jar 包和前端的 dist 静态资源目录。后端打包只需 Maven 执行 mvn clean package成功后 target 目录下会生成一个 jar 包接着在服务器上执行 java -jar food-delivery.jar --spring.profiles.activeprod 启动。前端打包是执行 npm run buildVite 会在 dist 目录生成一堆静态文件。上线时最省事的方案是直接用 Nginx 托管 dist 目录同时把 /api 请求反向代理到后端端口。我的 Nginx 配置核心部分长这样server { listen 80; server_name your-domain.com; root /var/www/food-delivery; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; add_header Access-Control-Allow-Origin *; } location / { try_files $uri $uri/ /index.html; } }最后那个 try_files 很关键它解决了 Vue Router 在 history 模式下刷新页面 404 的问题。如果不配置用户停留在商品详情页刷新一下就会报 404而实际原因是 Nginx 找不到对应的真实文件。当然更省心的方法是直接把前端打包产物复制进 Spring Boot 的 static 目录只需要启动一个 jar 包不过这种方式不太符合工程规范不容易体现项目水平。5.4 常见异常速查表我整理了一份自己在开发过程中最常遇到的报错和对应解决方案按照经验优先级排的可以帮你省掉不少搜索时间。现象原因解决方法前端调用接口报CORS错误前后端端口不同跨域未配置后端加跨域配置或用代理登录接口正常业务接口401请求头没带token在axios请求拦截器中统一加拦截器拦截了OPTIONS请求未处理预检放行OPTIONS请求中文乱码数据库或连接串字符集不对统一使用utf8mb4连接串加characterEncoding刷新页面404路由history模式Nginx配置try_files fallback端口被占用Java进程起不来上次进程未关闭kill旧进程或换端口Maven依赖下载慢或失败镜像源问题配置阿里云镜像仓库图片上传成功但访问404没有做静态资源映射配置虚拟路径映射到磁盘目录时间字段显示差8小时时区问题JVM时区和数据库时区统一为Asia/Shanghai5.5 一个很实用的项目亮点支付宝沙箱支付外卖系统的支付部分如果只做一个“模拟支付成功”的按钮答辩时容易被追问“真实支付怎么做”。我自己后面接入了支付宝沙箱环境这是支付宝官方提供的测试环境只需要在开放平台注册一个开发者账号用沙箱版支付宝 App 扫码就能完成模拟支付。支付宝沙箱接入的核心流程是后端生成支付订单号调用支付宝的电脑网站支付接口获得一段表单 HTML后端返回给前端后前端用 form 表单提交跳转到支付宝收银台页面。用户扫码完成支付后支付宝会异步通知你的回调地址后端在回调接口里校验签名确认无误后把本地订单状态改成已支付或待接单。这里需要强调的是业务上不能信任同步跳转通知必须老老实实用异步通知的结果更新订单状态因为异步通知才是支付宝官方确认支付成功的最终凭据。如果时间紧张至少也要把“用户支付成功后订单状态会更新”这个流程跑通。6. 答辩高频问题与项目亮点提炼6.1 老师最喜欢问的几个问题之前帮学弟梳理答辩问题时我发现老师们对外卖系统的提问方向基本固定集中在几个地方。第一个“为什么选择前后端分离”回答时要从开发效率、职责清晰、独立部署三个角度讲第二个“是如何解决并发下单超卖的”可以通过 SQL 带条件扣减或者 Redis 分布式锁来回答第三个“购物车数据是存在哪里”简单实现存在后端数据库的 cart 表更优的可以回答用 Redis 存储以用户 ID 为 key菜品 ID 为 hash key第四个“订单状态流转是怎么控制的”用状态机校验来回答。遇到不会的问题千万不要直接说“我没想到”或“我没做”可以先明确说出自己的实现方案再顺着老师的问题补一句“如果要进一步优化我可以尝试……”这样就体现了一个开发者的思考深度。比如老师问“你的登录 token 被别人截获怎么办”你可以正面回答“JWT 最常见的风险是 token 被盗用解决思路包括设置合理的过期时间、使用 HTTPS 加密传输以及有需要的场景下将 token 存到 Redis 并增加续期机制”即使没实现也能给老师留下思考全面的印象。6.2 技术亮点不要只停留在表面很多毕设项目写着“用了 Spring Boot Vue MySQL”但真正的亮点是具体场景下的业务设计。我可以复盘一下自己这个项目里最能拿得出手的几个点。一个是订单明细快照设计从数据一致性上解释了为什么不能直接关联菜品表这个思路在真实项目里经常用到另一个是 Redis 缓存菜品分类或店铺信息减少数据库压力同时可以在 Redis 里存储热点数据还有一个是我在部署时用 Docker 统一管理 MySQL、Redis、后端服务写了一个 docker-compose.yml一条命令启动整套环境。如果你也想做亮点我建议从并发控制、缓存设计、接口安全、部署自动化里面选一两个方向深挖。贪多嚼不烂项目最重要的不是用了多少技术而是能否把用到的一两个东西讲深讲透让老师看到你确实在独立思考而不是机械地抄代码。我个人做完这套外卖系统之后最大的体会是毕业设计也好自学的实战项目也好做出来不是终点能把设计思路、踩过的坑、每个关键选择背后的理由讲清楚才是一次完整的技术训练。点餐系统看着不难做了之后才能真正理解订单状态、数据一致性、前后端协作这类行业里的经典问题。如果后续想再往上扩加个会员模块、优惠券满减、附近店铺推荐、在线聊天客服都是很好的方向这套骨架和思路完全撑得住。你手头的需求如果跟这个类似照着上面的流程一步一步来即可先把表和流程理清再动手写代码你会发现自己踩的坑会少很多。