☰
Spring Boot + Vue全栈实战:美食网站从零搭建完整流程
2026/10/1 22:31:03 网站建设 项目流程

几个月前有个学弟在后台问我,想拿一个美食网站当毕业设计,让我推荐后端框架。他纠结了很久是用SSH还是SSM,我回了他一句:别折腾了,直接用Spring Boot搭后端、Vue写前端,前后端分离。两个月后他把项目演示视频发过来,效果相当能打。

说真的,Java + Spring Boot + Vue 这个组合,放在今天就是一个默认答案。它不花哨,但足够稳,资料多、教程全、坑都被前人踩平了,一个人从零到完整系统跑通完全可行。这篇文章我就拿“美食网站”这个具体场景,把从技术选型、数据库设计、后端接口、前端页面到联调部署的完整流程,掰开揉碎讲一遍。项目不复杂,但覆盖的知识点很典型,不管是做课程设计、毕业设计,还是想自己写一个完整项目练手,都有参考价值。


1. 项目整体设计与技术选型

1.1 为什么是Spring Boot + Vue,而不是其他组合

这个选择不是跟风,是经过对比后的最优解。先说后端。Spring Boot 最大的价值在于“自动配置”和“起步依赖”,它把传统SSH里那些繁琐的XML配置全都干掉了。你引入一个 spring-boot-starter-web,一个能跑起来的Web工程就成了;再引入 starter-data-jpa 或 mybatis-plus,数据访问层也搞定了。对于一个人要搞定的中小型项目来说,这完全够用。

从热词里能看出来,很多人搜索“第一个spring boot程序”“spring boot快速创建一个springboot项目”,说明这确实是Java Web入门的主流路径。Spring Boot把项目初始化变成了一个“选依赖、生成、打开”的过程,Java新手不用再为配置环境消耗大量时间,能更快把精力放在业务逻辑本身。

再说前端。Vue是渐进式框架,可以当简单的页面增强工具用,也可以配Vue Router、Pinia、Axios搭出完整的单页应用。它在国内有庞大的中文社区和生态,Element Plus这类组件库能直接把后台管理界面拼出来。前端部分不会成为新手学习的瓶颈——你要做的不是从零写复杂交互,而是把页面结构、路由跳转、数据请求这三件事理顺。

前后端分离的结构还有一个额外的好处:接口即契约。后端把接口定义好,返回统一格式的JSON;前端只管对接、渲染。两边的代码互相不掺和,单人开发时逻辑边界也清晰,不会出现“改一个页面把后端类也改了”的情况。

提示:如果你完全没接触过前端,不建议一上来就追求复杂的工程化结构,先把Vue的基础指令、组件通信、路由和Axios请求搞明白,再考虑构建工具、状态管理的优化。

1.2 系统功能模块与角色权限怎么拆

美食网站的核心业务很明确:用户看菜、找菜、点菜(下单购买这里可以做成点餐/外卖或预约模式),管理员维护菜品和订单。

常规情况下,我会把系统拆成两大端:

前台用户端功能:

  • 用户注册、登录、个人信息维护
  • 首页轮播图、热门菜品推荐、按分类浏览菜品
  • 菜品详情页(图片、价格、销量、评分、简介)
  • 关键词搜索、分类筛选、分页加载
  • 收藏菜品、加入购物车、购物车管理
  • 提交订单、订单列表与状态查看
  • 菜品评论与评分

后台管理端功能:

  • 管理员登录
  • 菜品管理:增删改查、上下架、图片上传
  • 分类管理:菜品分类的维护
  • 订单管理:订单列表、发货/完成操作
  • 用户管理:用户列表、禁用/启用
  • 数据看板:菜品数量、用户数、订单量等信息展示

权限控制不需要做得太重。用拦截器加角色判断就能满足大部分需求:用户角色的接口要求登录后访问,管理员的接口要求角色为“admin”。如果要用Spring Security,那也是只引入spring-boot-starter-security做简单的配置,不要太早陷进安全框架的复杂细节里。

模块划分清楚后,项目的整体结构就很清晰:后端提供接口,前端组织页面,数据库存数据源。这个“不是很难但流程完整”的特点,正是练手项目最好的状态。


2. 数据库设计与后端核心要点

2.1 数据表到底要建几张才够

很多新手一开始会想把所有字段塞进一张大表里,或者反过来拆得太碎。美食网站这种规模的项目,用下面这8张表就足够了,缺一张就会缺一块核心功能。

表名作用核心字段
sys_user用户表id、username、password(加密码加密)、nickname、avatar、role、status
food_category菜品分类表id、name、sort
food菜品表id、category_id、name、description、price、image、stock、sales、status
food_collect收藏表id、user_id、food_id、create_time
cart_item购物车表id、user_id、food_id、quantity、selected
food_comment评论表id、user_id、food_id、content、rating、create_time
order_info订单表id、order_no、user_id、total_price、status、create_time、pay_time
order_item订单明细表id、order_id、food_id、food_name、food_price、food_image、quantity

这里我特别说一下订单表和订单明细表的拆分逻辑。订单是一次性行为,用户下了单之后,菜品后续改价、改名都不应该影响这个订单的历史记录,所以订单明细里要冗余保存菜的“快照信息”(名称、价格、图片),而不是下单时再去关联菜品表查询。这不是过度设计,是订单系统的基本常识。

关于外键,我建议逻辑外键而不是物理外键。也就是说,表里保留 user_id、category_id 这样的关联字段,但不要用数据库的 FOREIGN KEY 约束。原因很简单:外键约束会带来级联更新和删除的麻烦,而且当数据量变大、系统需要拆分表的时候,物理外键会成为阻力。项目里通过代码控制关联关系,是更灵活的方案。

2.2 后端接口怎么分层,代码才不会写成一坨

后端代码的分层,我强烈建议遵守经典的 Controller + Service + Mapper(或Repository)三层架构。它维护起来最顺手,面试时讲出来也体面。

Controller 层只做参数接收、调用Service、返回统一结果,不能在里面写业务逻辑。Service 层写业务规则,例如下单时减库存、订单状态流转的判断。Mapper/Repository 层只负责数据库操作。

举个例子,这是订单提交的核心Service方法骨架:

@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderInfoMapper orderInfoMapper; @Autowired private OrderItemMapper orderItemMapper; @Autowired private FoodMapper foodMapper; @Override @Transactional(rollbackFor = Exception.class) public OrderInfoVO createOrder(Long userId, List<CartItemDTO> cartItems) { // 1. 计算订单总价,校验库存 BigDecimal totalPrice = BigDecimal.ZERO; List<OrderItem> itemList = new ArrayList<>(); for (CartItemDTO dto : cartItems) { Food food = foodMapper.selectById(dto.getFoodId()); if (food == null || food.getStock() < dto.getQuantity()) { throw new BizException("菜品库存不足: " + food.getName()); } totalPrice = totalPrice.add(food.getPrice().multiply(new BigDecimal(dto.getQuantity()))); // 构建订单明细 OrderItem item = new OrderItem(); item.setFoodId(food.getId()); item.setFoodName(food.getName()); item.setFoodPrice(food.getPrice()); item.setFoodImage(food.getImage()); item.setQuantity(dto.getQuantity()); itemList.add(item); } // 2. 创建订单主记录 OrderInfo order = new OrderInfo(); order.setOrderNo(OrderNoUtil.generate()); order.setUserId(userId); order.setTotalPrice(totalPrice); order.setStatus(0); // 0待付款,1已付款,2已发货,3已完成,4已取消 orderInfoMapper.insert(order); // 3. 插入订单明细 for (OrderItem item : itemList) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } // 4. 扣减库存 for (CartItemDTO dto : cartItems) { foodMapper.decrStock(dto.getFoodId(), dto.getQuantity()); } return buildVO(order); } }

这段代码里有个明显的技术点:@Transactional注解。没有事务的情况下,如果第3步插明细失败了,订单主记录却已经写进数据库,数据就不一致了。加了这个注解,任何一步抛出异常,整个订单创建过程都会回滚,数据库会保持原样。这正是很多开发者在热词里搜“java怎么保证数据一致性”时想明白的事情——事务就是最基础的一致性保障手段之一。

统一返回结果也很重要。我在项目里习惯定义 Result 类,包含 code、message、data 三个字段:成功时为 Result.ok(data),失败时 Result.error(msg)。前端拿着这个结构统一处理,就不用每次对乱七八糟的返回格式单独做解析。

实操心得:后端返回前端的时间字段,最好格式化好再返回,比如统一为"yyyy-MM-dd HH:mm:ss"。否则前端拿到的可能是带T的UTC时间字符串或一串毫秒数,渲染时还得再做转换。

2.3 菜品图片上传与访问路径处理

美食网站没有图片是没法看的。用户访问首页第一眼看到的就是菜品图,所以接口设计里必须包含图片上传能力。

比较简单可靠的上传方案:后端文件上传接口把 MultipartFile 保存到本地上传目录,然后返回一个可访问的静态资源URL。Spring Boot 里配置一下资源映射即可:

spring: servlet: multipart: max-file-size: 10MB max-request-size: 30MB resources: static-locations: classpath:/static/,file:${upload.path}

这样配置后,上传目录通过 http://localhost:8080/upload/xxx.jpg 可以直接访问。前端把接口返回的相对路径拼到请求前缀后,就能正常渲染图片。

需要说明的是,本地存储只适合开发和学习场景。如果项目要部署到生产环境,建议替换为对象存储(OSS、MinIO等)。把上传服务封装成一个接口、一个实现类,以后替换底层实现时不需要改动Controller层代码,这是面向接口编程的价值。


3. 前端搭建与核心页面实现

3.1 环境准备:Vue项目初始化与依赖安装

前端部分我用的是 Vue 3 + Vite 组合,比 Vue 2 的脚手架快不少,对新手也友好。初始化项目的命令:

# 使用 Vite 创建 vue 项目 npm create vite@latest food-web -- --template vue cd food-web # 安装基础依赖 npm install vue-router@4 pinia axios element-plus

对应热词里提到的“vue安装及环境配置”“vue安装依赖”,这里要提醒几个常见的坑:

  • Node.js 版本不要太老,Vite 要求 Node 16 及以上,建议直接用 18 或 20 的 LTS 版本。
  • Element Plus 的按需导入需要额外配置 unplugin-auto-import 和 unplugin-vue-components,如果不想折腾,直接全局引入最省事:
// main.js import { createApp } from 'vue' import App from './App.vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' const app = createApp(App) app.use(ElementPlus)

项目规模不大,全量引入打包后体积也不会有明显问题,开发体验上还挺顺畅。

3.2 前端路由设计与页面结构

路由可以规划为两组:普通用户访问的页面和管理员后台。我用 Vue Router 的嵌套路由把管理员子页面挂在同一个 Layout 下面。

// router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', redirect: '/home' }, { path: '/', component: () => import('@/layout/FrontLayout.vue'), children: [ { path: 'home', name: 'Home', component: () => import('@/views/front/Home.vue') }, { path: 'food/:id', name: 'FoodDetail', component: () => import('@/views/front/FoodDetail.vue') }, { path: 'cart', name: 'Cart', component: () => import('@/views/front/Cart.vue') }, { path: 'order', name: 'Order', component: () => import('@/views/front/Order.vue') }, ] }, { path: '/admin', component: () => import('@/layout/AdminLayout.vue'), redirect: '/admin/food', meta: { requiresAuth: true, role: 'admin' }, children: [ { path: 'food', component: () => import('@/views/admin/FoodManage.vue') }, { path: 'category', component: () => import('@/views/admin/CategoryManage.vue') }, { path: 'order', component: () => import('@/views/admin/OrderManage.vue') }, { path: 'user', component: () => import('@/views/admin/UserManage.vue') }, ] }, { path: '/login', component: () => import('@/views/Login.vue') }, ]

懒加载通过() => import()实现,这样首屏不会加载全部页面组件。Vue Router 只会加载当前访问页面的代码,对体验提升很明显。

3.3 列表页、详情页、购物车交互怎么实现

前台首页是最关键的一个页面,它直接决定用户的第一印象。我会用轮播图放推荐菜品,再用卡片网格展示菜品。核心思路很简单:调用后端的菜品分页接口,把返回的数组渲染进el-row+el-col布局里。

卡片组件大致长这样:

<template> <el-card :body-style="{ padding: '0px' }" class="food-card"> <img :src="food.image" class="food-image" @click="goDetail(food.id)" /> <div class="food-info"> <h3>{{ food.name }}</h3> <p class="desc">{{ food.description }}</p> <div class="price-line"> <span class="price">¥{{ food.price }}</span> <span>已售 {{ food.sales }}</span> </div> <el-button type="primary" size="small" @click="addCart(food.id)" icon="ShoppingCart"> 加入购物车 </el-button> </div> </el-card> </template>

加入购物车的交互,我在项目里走了两步:先检查用户是否已登录,未登录则跳转登录页;登录后调用后端接口,把菜品ID和数量传给后端,后端负责合并同菜品、累加数量。这个逻辑放后端,能避免前端处理用户登录状态不一致导致的数据错乱。

购物车页面的操作要顺手一些。数量加减、全选单选、勾选后动态计算合计金额,这些用 Vue 的响应式数据配合计算属性就能实现。计算属性的典型用法是:

const selectedItems = computed(() => cartStore.data.filter(item => item.selected) ) const totalPrice = computed(() => selectedItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0) )

这样用户每改一次勾选状态,合计金额会自动更新,不需要手动维护变量。

注意:购物车页面千万不能只在前端计算合计,提交订单时必须以后端重新计算的金额为准。前端金额只是展示,后端要重新校验一番,否则用户改一下浏览器里的请求参数,订单金额就直接被篡改了。


4. 前后端联调与关键配置细节

4.1 跨域问题是怎么冒出来的,怎么彻底解决

前后端分离开发时,前端跑在 http://localhost:5173,后端跑在 http://localhost:8080,浏览器发请求时会因为“源”不同而触发跨域拦截。这是开发初期遇到最多的问题之一。

网上能看到很多解决方案,但我建议开发阶段直接用 Vite 的代理配置解决,不要去后端写 CORS 过滤器到处放开。

// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } } })

也就是说,前端请求/api/user/login,Vite 开发服务器会把它转发为http://localhost:8080/user/login。浏览器上显示的请求还是同源的,不会触发跨域拦截。后端不用做任何CORS配置,干净利落。

生产环境时,再把/api的转发放到 Nginx 里,例如:

location /api/ { proxy_pass http://127.0.0.1:8080/; }

开发代理和后端过滤器的思路类似,但转发逻辑更好控制,项目部署时也不会有残留的“把所有跨域都放开”的隐患。

4.2 登录态保持与Token方案

前后端分离项目,登录态基本都用Token方案来实现,而不是Session。因为后端接口可能被多个客户端(Web、小程序、App)调用,Token 是更通用的凭证。

流程很简单:用户登录成功后,后端生成一个 Token 返回给前端;前端存到 localStorage 或 Pinia 里;后续请求在请求头带上Authorization: Bearer <token>;后端校验 Token 是否有效、用户是否存在。

Axios 请求拦截器和响应拦截器是这里最重要的配置,把重复工作收敛掉:

// utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:自动携带 token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一处理错误 code request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { if (error.response?.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } ) export default request

这里有一个被新手忽略的细节:后端统一返回的code字段不能等于 HTTP 状态码。HTTP 200 只能代表网络请求成功,业务上的成功还是失败要用code自己约定,比如 200 表示业务成功,401 表示未登录,500 表示服务端异常。这样前端拦截器处理起来用一处逻辑覆盖所有接口,不用每个接口单独写错误分支。

路由守卫通常配合 Token 一起使用。比如未登录用户访问购物车、订单页面,直接弹回登录页;非管理员访问/admin下的页面,提示无权限:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') return } if (to.meta.role) { const role = localStorage.getItem('role') if (role !== to.meta.role) { next('/home') return } } next() })

4.3 后端自定义Token校验怎么做

如果不想引入 Spring Security 的复杂过滤器链,我自己常用的方式是实现一个 HandlerInterceptor,在拦截器里完成Token校验。

@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private UserService userService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); Long userId = JwtUtil.parseToken(token); if (userId != null) { User user = userService.getById(userId); if (user != null) { request.setAttribute("userId", userId); request.setAttribute("role", user.getRole()); return true; } } } response.setStatus(401); response.setContentType("application/json;charset=utf-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } }

然后注册进 WebMvcConfigurer:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/**") .excludePathPatterns("/user/login", "/user/register", "/food/**", "/category/**", "/upload/**"); } }

这样一个登录校验框架就搭起来了,代码量不大,但每个环节都能看懂。比一上来就用 Spring Security 里一堆 SecurityFilterChain 配 SecurityConfig 更贴合毕业设计的水平。


5. 常见问题与避坑速查表

5.1 项目运行不起来,先查这几个地方

遇到启动失败、请求异常,90%是下面这三类问题:

端口冲突。后端默认8080,前端Vite默认5173,如果本机有其他服务占用了这些端口,启动时会报Port already in use。解决方式:后端在application.yml里改server.port;前端改 Vite 的server.port。

数据库没连上。报Cannot create PoolableConnectionFactory或者Unable to connect to the database,去检查application.yml里的数据库名、用户名、密码是否对了、MySQL服务有没有启动。这里我要着重说一句:serverTimezone=Asia/Shanghai这个参数最好加上,否则服务器和本地时区不一致,日期存进数据库后读出来会差8个小时。

spring: datasource: url: jdbc:mysql://localhost:3306/food_website?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver

参数对不上。后端接口参数名和前端传的参数名拼写不一致,前端报参数为空或400。这是前后端联调最常见的问题,没有捷径,做好接口文档、写清楚字段名,能省下大把调试时间。

5.2 联调阶段的经典报错排查

现象可能原因解决方式
列表接口返回正常,但页面空白前端访问的字段名和后端JSON字段对不上浏览器控制台看Network里Response的字段,严格按后端返回的key取值
图片加载不出来图片路径缺失域名/IP,或上传目录资源映射没配置确认静态资源映射路径和上传路径是否一致
登录后刷新页面就掉线Token只存在内存变量里,刷新后丢失改存 localStorage
退出登录后还能访问管理页面路由守卫没有做角色判断在beforeEach中读取本地存储的 role 并判断
订单生成失败但没报错事务未生效,中间步骤出错后部分数据写入检查 Service 方法是否被同类内部调用,事务需要跨类调用才生效

关于“事务未生效”这一点,值得多说一句。Spring 事务是基于代理实现的,如果在一个类内部通过this调用另一个方法,事务是不会生效的。比如createOrder方法自己调自己的内部私有方法,代码不报错,但事务回滚就是无效。解决方式是拆成两个不同的 Bean,或者通过 self-injection 调用自己的代理。

5.3 一些实操中的良心建议

第一,前后端字段名在刚开始定义接口时就要统一,比如后端叫userId,前端一会儿写userid、一会儿写uid,联调时找错能找半小时。我的做法是先定义好接口文档,再动手写两端代码,字段统一用驼峰风格。

第二,不要追求把所有功能都做进去就完事,代码质量比功能数量重要。一台服务器、一个数据库、8张表、三四十个接口,加上十几个前端页面,这个规模才是合理的。堆功能不仅会增加完不成风险,还会让论文或总结时的“工作量描述”变得心虚。

第三,数据填充一定要认真做。美食网站最大的问题就是“做出来了但看起来像无人机房”——分类只有几条、菜品图是默认图、评价一条没有。我建议大家把菜品图片换成真实拍摄图或设计图,把分类做成“川菜、粤菜、甜点、饮品”这种真实的类目。用户点进来第一眼的体验,直接影响整个项目的观感分。

第四,缓存和性能优化可以在核心模块做一个示范。比如热门菜品榜单这个接口,每次刷新首页都要查询一次数据库,这是典型的适合加缓存的场景。引入 spring-boot-starter-cache 配合 Caffeine 做本地缓存,把热门菜品的查询结果缓存60秒,再顺手写一个@Cacheable注解,性能和代码简洁度都能展示出来。类似的技术点其实还有 WebSocket 做订单状态实时提醒、Saas 化的多商户支持,这些在标题对应的热词里都有人搜索,但毕设项目中挑一个点做深即可,不要全都堆砌。


写在最后的个人体会

做这类全栈项目,我的习惯是“先跑通,再打磨”。整个美食网站最核心的一条链路,其实就是用户注册登录、浏览菜品、加入购物车、提交订单、管理员处理订单。先把这条链路在本地完整跑通,哪怕页面丑一点、代码LOW一点,都没关系——系统能转起来,所有数据能闭环,这个阶段的意义就达到了。之后再去美化页面、加缓存、做数据统计、优化代码结构,每一步都是在完整系统上的加工。反过来,如果一上来就纠结某个页面设计得多漂亮、某个接口用哪个设计模式,很容易卡死在半路。

这个项目还有很大的扩展空间,比如接入小程序端、增加多商户入驻、引入消息推送、做菜品推荐算法,甚至把前后端打包成Docker镜像一键部署。无论如何,Spring Boot + Vue 这个技术栈本身的能力边界足够宽,你在这个项目里学到的分层、接口设计、状态管理、跨域处理、Token鉴权这些知识,换一个业务场景照样能用得上。

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

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

立即咨询