
简介Java毕业设计项目外卖点餐系统基于Spring Boot Vue Vant Element-UI开发面向计算机相关专业毕业生及前后端分离项目学习者既可作毕业设计选题也可用于课程设计或项目实战练手覆盖后端接口、移动端H5与管理后台的完整开发链路。压缩包共282个文件含60个Java源码、51个Vue页面、92张PNG界面图及39张JPG截图另附SQL脚本、YML配置文件与Markdown说明文档包体约14.17MB目录划分清晰。已有5238人浏览学习。项目提供完整可运行代码、环境依赖配置、界面效果预览及数据库初始化脚本内容预览中的OrderServiceImpl、ShopServiceImpl等核心Java文件可帮助读者快速理解点餐、店铺管理、订单流转等业务实现便于二次开发与论文撰写。1. 从课程设计到可商用这套外卖系统到底做了什么外卖点餐系统是个被做了无数遍的选题但多数毕业设计停留在「能跑」层面单体 JSP、一次性 SQL、前端套个 Bootstrap 后台模板。而 Spring Boot Vue Vant Element UI 这个组合背后意味着你要交付的是一个前后端分离、移动端与 PC 端双入口、接口可被微信小程序复用的完整工程。Vant 负责 H5 点餐端Element UI 负责管理后台Spring Boot 提供 RESTful API这套架构在真实的外卖 SaaS 项目里也是常见形态。本文不聊需求分析文档怎么写直接拆解一个可复现的落地路径后端工程怎么搭、管理员端与用户端接口怎么设计、移动端与 PC 端组件怎么选、文件上传与订单状态机怎么处理再到部署上线前必须改掉的几个安全漏洞。如果你正卡在「表建好了但接口不会写」或者「接口写完了但前端不会调」这篇文章能让你少走两天弯路。2. Spring Boot 后端餐饮业务的表结构设计与接口分层2.1 外卖核心表的设计思路订单、购物车与菜品 SKU外卖系统的表结构并不复杂但「订单」与「购物车」两张表的设计决定了后续开发的顺畅程度。菜品表dish建议采用「一表两态」的方式category_id关联分类status字段控制上下架而不是为每个状态建独立表。规格大小份、辣度不要直接拼在dish表里单独建dish_flavor表用逗号分隔的可选项存储比如[不辣,微辣,中辣]。订单表是重中之重。orders表必须冗余用户地址快照address、phone、consignee因为用户后续修改地址不能影响历史订单。订单明细表order_detail冗余菜品名称和图片原因相同菜品改名或删除后商家端和用户端的历史订单仍要正常展示。这里最常见的错误是在order_detail里只存dish_id查询时 JOIN 菜品表结果菜品一删历史订单直接报错或显示空白。购物车表shopping_cart建议以user_id dish_id dish_flavor作为唯一逻辑键。同一种菜品不同口味要能同时存在于购物车否则「加辣」和「不辣」会被合并成一条数量累加到错误的口味上。2.2 用 Spring Boot 搭建 RESTful API 的分层结构后端工程建议按controller-service-mapper三层切分不要用传统的dao层命名。Controller 只做参数接收和结果封装Service 处理业务逻辑Mapper 负责数据库交互。使用 MyBatis Plus 能显著减少单表 CRUD 的样板代码但多表关联查询必须手写 XML不要用TableField(exist false)强行映射关联对象。pom.xml中需要引入的核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.18/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependencyapplication.yml中除了常规的数据源配置还要设置 MyBatis Plus 的驼峰映射和逻辑删除mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0逻辑删除字段建议在所有业务表上统一添加。外卖系统的菜品和分类删除操作如果走物理删除历史订单的关联查询会直接断链逻辑删除能保证order_detail里的冗余数据仍然完整。map-underscore-to-camel-case开启后数据库字段create_time会自动映射为createTime避免在实体类里写一堆TableField注解。2.3 统一返回结果与全局异常处理的规范前后端分离项目必须在后端定义统一的返回体否则前端每个接口都要写一套不同的解构逻辑。常见的规范是code msg data三段式Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(1); result.setMsg(success); result.setData(data); return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.setCode(0); result.setMsg(msg); return result; } }这里的code不建议用 HTTP 状态码直接代替。HTTP 状态码表达的是传输层语义而业务码表达的是业务层结果——比如「菜品已下架」是一个正常的 HTTP 200 响应但code可以是 0前端拿到后弹出提示并刷新列表。用RestControllerAdvice做全局异常捕获业务异常统一抛出BusinessException避免每个 Service 方法里都写 try-catch。提示全局异常处理里SQL 异常和空指针异常不要直接返回异常信息给前端统一记录日志后返回「系统繁忙」即可。外卖系统的支付回调、订单状态变更等场景直接暴露数据库错误信息可能被恶意利用。3. Vue Vant 移动端点餐端从购物车到支付的完整交互3.1 Vant 组件库的按需引入与移动端适配方案用户端点餐端建议使用 Vue 3 Vant 4 组合通过unplugin-vue-components实现组件按需自动导入。Vant 4 只支持 Vue 3如果你的项目是 Vue 2需要锁定 Vant 2.x 版本。安装命令如下npm i vant4 npm i -D unplugin-vue-componentsvite.config.js中配置自动导入import { defineConfig } from vite import vue from vitejs/plugin-vue import Components from unplugin-vue-components/vite import { VantResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), Components({ resolvers: [VantResolver()] }) ] })移动端适配采用postcss-px-to-viewport让设计稿上的 px 自动换算为 vw。外卖点餐界面通常在微信内置浏览器或 App WebView 中打开postcss-px-to-viewport的配置建议viewportWidth: 375以 iPhone 6/7/8 为基准unitPrecision: 5保留换算精度。3.2 分类联动滚动与购物车状态管理点餐页的核心交互是左侧分类、右侧菜品列表的联动滚动。Vant 的Sidebar组件配合IndexBar无法直接实现类美团的双向滚动常见做法是左侧Sidebar绑定active索引右侧ScrollView监听滚动位置实时计算当前所处的分类。template div classmeal-container van-sidebar v-modelactiveCategory changeonCategoryChange van-sidebar-item v-foritem in categoryList :keyitem.id :titleitem.name / /van-sidebar div classdish-list refscrollRef scrollonScroll div v-forcourse in dishGroup :keycourse.categoryId :idcategory- course.categoryId classdish-group h3{{ course.name }}/h3 van-card v-fordish in course.dishList :keydish.id :titledish.name :thumbdish.image :pricedish.price template #num van-stepper :model-valuegetCartCount(dish.id) changeonStepperChange($event, dish) / /template /van-card /div /div /div /template这段代码的核心逻辑在onScroll方法里通过getBoundingClientRect()获取每个dish-group距离容器顶部的偏移量找到最后一个偏移量为正的分类更新activeCategory。反向联动时点击左侧分类滚动右侧使用scrollIntoView将对应category-开头的元素滚动到可视区域。购物车状态管理建议用 Pinia 而不是组件内ref维护。原因是购物车数据需要被底部结算栏、菜品列表的 stepper、购物车弹出层三个组件共享export const useCartStore defineStore(cart, { state: () ({ items: [], cartVisible: false }), getters: { totalCount: (state) state.items.reduce((sum, item) sum item.number, 0), totalPrice: (state) state.items.reduce((sum, item) sum item.number * item.price, 0), cartList: (state) state.items.filter((item) item.number 0) }, actions: { addItem(dish) { const existing this.items.find((item) item.dishId dish.id) if (existing) { existing.number } else { this.items.push({ ...dish, number: 1 }) } } } })这里有一个容易踩的坑购物车项存到 Pinia 后是响应式代理对象提交订单时直接传给后端会在序列化时带上额外的__v_isRef等 Vue 内部属性。提交前用JSON.parse(JSON.stringify(cartList))做一次深拷贝或者只提取需要的字段。3.3 订单提交与支付流程中的状态处理外卖系统的订单状态机建议设计为待支付 → 待接单 → 配送中 → 已完成 / 已取消。用户端提交订单后进入「待支付」状态模拟支付成功后调用「支付回调」接口将订单置为「待接单」。这个流程在后端接口设计上要分开/order/submit只负责创建订单并返回订单号/order/pay负责异步扣款成功后更新状态。前端用 Vant 的Dialog组件做支付确认支付成功后跳转订单详情页并清除购物车数据const onSubmitOrder async () { const payload { userId: userStore.userInfo.id, addressBookId: selectedAddress.value.id, cartList: JSON.parse(JSON.stringify(cartStore.cartList)) } const { data } await http.post(/order/submit, payload) if (data.code 1) { Dialog.confirm({ title: 模拟支付, message: 应付金额${cartStore.totalPrice} 元 }).then(async () { await http.post(/order/pay, { orderId: data.data.id }) cartStore.clearCart() router.push({ path: /order/detail, query: { orderId: data.data.id } }) }) } }这个模拟支付的交互一定要做因为毕设答辩时评委大概率会问「支付环节是怎么处理的」。直接说「调了微信支付接口」但拿不出支付凭证反而扣分明确说明这是沙箱模拟更适合课程设计场景。4. Element UI 管理后台与 JWT 登录鉴权的完整实现4.1 用 Vue CLI 创建后台工程并引入 Element UI管理后台面向商家和管理员使用 Vue 2 Element UI 仍是主流选择。虽然 Element Plus 已经成熟但 Element UI 的生态和既有项目模板更多如果你是参照开源项目二次开发Vue 2 版本兼容性更好。创建工程并安装依赖vue create admin-frontend cd admin-frontend npm i element-ui npm i axios vue-router3 vuex3main.js中全局注册 Element UI 组件import Vue from vue import ElementUI from element-ui import element-ui/lib/theme-chalk/index.css import App from ./App.vue import router from ./router import store from ./store Vue.use(ElementUI) new Vue({ router, store, render: (h) h(App) }).$mount(#app)注意Element UI 2.x 版本对应 Vue 2.x安装时如果不指定版本最新的 Element UI 2.15.x 仍然可以正常使用。管理后台的布局建议用el-container嵌套el-aside和el-main左侧菜单用el-menu配合router模式实现路由跳转。4.2 Spring Boot 集成 JWT 做登录鉴权与拦截器管理后台必须做登录鉴权最轻量的方案是 JWTJSON Web Token不需要在 Redis 里维护 session。JWT 的载荷里放userId、username、role三个字段有效期建议设置为 2 小时过期后前端通过拦截器跳回登录页。后端引入依赖dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependencyJWT 工具类中生成与解析的核心代码public class JwtUtils { private static final String SECRET_KEY your-secret-key-must-be-at-least-32-bytes-long; private static final long EXPIRE_TIME 2 * 60 * 60 * 1000; public static String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(username, username) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); } }SECRET_KEY必须足够长HS256 算法要求密钥长度不少于 32 字节否则启动时直接抛异常。实际项目中这个密钥不能硬编码在类里应该放到application.yml中通过Value注入部署时用环境变量覆盖。拦截器处理登录校验的核心逻辑public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { try { Claims claims JwtUtils.parseToken(token.substring(7)); request.setAttribute(userId, claims.get(userId)); request.setAttribute(username, claims.get(username)); return true; } catch (Exception e) { // token 无效或过期 } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:0,\msg\:\未登录或登录已过期\,\data\:null}); return false; } }拦截器里OPTIONS请求必须直接放行——前端跨域请求时会先发预检请求预检请求不携带 Authorization 头且不该走业务拦截逻辑否则前端所有请求都会报 401。提示管理后台如果用到图片上传上传接口也应该在拦截器白名单中排除或单独校验。因为multipart/form-data请求的 Header 有时会因为前端封装问题丢失 Authorization排查时优先看浏览器的 Network 面板确认请求头是否完整。4.3 Element UI 表格、表单校验与图片上传管理后台的菜品管理页面是 Element UI 的典型组合el-table展示列表、el-dialog嵌套el-form做新增编辑、el-upload做图片上传。菜品图片上传建议后端做成通用接口返回图片的访问 URLPostMapping(/common/upload) public ResultString upload(MultipartFile file) throws IOException { String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName System.currentTimeMillis() _ UUID.randomUUID().toString().substring(0, 8) suffix; String datePath new SimpleDateFormat(yyyyMMdd).format(new Date()); File dir new File(basePath datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir.getAbsolutePath() / fileName)); return Result.success(/upload/ datePath / fileName); }这里文件名用时间戳加随机数拼装避免中文文件名导致乱码和路径穿越问题。transferTo方法要求目标目录已存在忘记mkdirs()是上传接口 500 的第一大原因。前端el-upload组件的核心配置如下el-upload classdish-uploader action/api/common/upload :headersuploadHeaders :show-file-listfalse :on-successhandleUploadSuccess img v-ifform.image :srcform.image classdish-cover / i v-else classel-icon-plus/i /el-uploadaction里的/api前缀要和生产环境的反向代理配置一致uploadHeaders从 Vuex 里取 token 拼成Authorization: Bearer xxx。5. 前后端联调、跨域处理与上线部署的坑5.1 跨域问题的全套解决方案开发环境下前端跑在 8080 端口Vue CLI 默认后端跑在 8081 端口浏览器会拦截跨域请求。最省事的方案是在前端vue.config.js配置 devServer 代理后端不用开启任何跨域配置module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true, pathRewrite: { ^/api: } } } } }后端接口不做/api前缀前端通过代理将/api/order/list转发为http://localhost:8081/order/list。这种方式的好处是生产环境只需将前端静态文件部署到 Nginx再配置同样规则的location /api反向代理即可代码里不需要区分环境切换 baseURL。如果你选择在后端开启 CORS注意 Spring Boot 的配置要允许携带凭证并明确指定允许的源Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:8080) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }allowedOrigins不要配置成*因为allowCredentials(true)时浏览器要求源必须是具体地址。联合使用拦截器时CORS 配置的优先级要高于拦截器否则预检请求会被拦截器拦截导致跨域失败。5.2 数据库初始化与测试数据的准备策略毕业设计答辩时最尴尬的场景是演示中途数据报错。schema.sql中除了建表语句还要准备充分的测试数据——至少 6 个菜品分类、每个分类下 5 道菜、合理价格的规格组合以及 2 个测试用户账号一个管理员、一个普通用户。数据库字符集统一使用utf8mb4原因很简单utf8mb4兼容 emoji 和特殊符号防止用户昵称里有表情导致插入失败CREATE DATABASE IF NOT EXISTS takeout DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;菜品价格字段建议使用DECIMAL(10, 2)而不是FLOAT或DOUBLE。浮点数在金额计算上存在精度问题0.1 0.2在二进制浮点数中并不等于0.3订单金额一旦计算错误演示时很难解释清楚。5.3 Nginx 部署配置与前端构建优化前后端分离项目的上线形态是Nginx 托管前端静态文件反向代理后端接口。Vue 项目构建后dist目录包含index.html和静态资源目录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:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /upload/ { alias /data/takeout/upload/; } }try_files配置解决了 Vue Router 的 history 模式刷新 404 问题——所有不存在的路径都回退到index.html由前端路由接管。location /api/的proxy_pass结尾带/和不带/有本质区别带/会去掉/api前缀再转发不带则原样转发配错的话后端 404。构建前记得修改vue.config.js的publicPath为相对路径或者你的实际部署子路径module.exports { publicPath: ./, productionSourceMap: false, outputDir: dist }productionSourceMap: false关闭源码映射不仅减小部署包体积更重要的是避免你的源码被浏览器开发者工具直接查看。6. 状态机驱动的订单流转与超时未支付自动取消订单状态如果只在 Service 层用 if-else 判断后期加需求比如「商家取消订单」「用户申请退款」会越来越难以维护。推荐用简单的状态机枚举来管理public enum OrderStatus { PENDING_PAYMENT(1, 待支付), PENDING_ACCEPT(2, 待接单), DELIVERING(3, 配送中), COMPLETED(4, 已完成), CANCELLED(5, 已取消); private final Integer code; private final String desc; }定义一张合法流转表作为审核校验的依据当前状态可流转状态触发角色待支付待接单、已取消用户支付/取消待接单配送中、已取消商家接单/拒单配送中已完成用户确认收货在OrderService中封装transition(orderId, currentStatus, targetStatus, operator)方法每次状态变更先校验合法性再执行更新。这套逻辑写进毕业设计说明书里是答辩时很有说服力的亮点。超时未支付订单的取消最稳妥的方案是使用 Redis 的过期键监听。没有引入 Redis 时用 Spring 的Scheduled定时任务轮询也是一种可行的简化方案Scheduled(cron 0 */5 * * * ?) public void cancelExpiredOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); orderMapper.cancelExpiredOrders(OrderStatus.PENDING_PAYMENT.getCode(), OrderStatus.CANCELLED.getCode(), deadline); }每条 SQL 批量更新 15 分钟前创建且仍处于待支付状态的订单。定时任务方案在毕设场景下够用而且能直观地在控制台看到执行日志比 Redis 过期事件更容易向评委解释。最后一个实用技巧后端接口的日期返回格式统一配置为yyyy-MM-dd HH:mm:ss否则前端拿到的时间戳是 UTC 格式差 8 小时用户看到订单创建时间是凌晨不对答辩时很容易被追问。在application.yml中加上spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这样前端展示订单时间时不需要额外写格式化函数整套系统的时间表现保持一致性。本文还有配套的精品资源点击获取