ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot + Vue美食网站开发实战:从零到一完整项目笔记

2026/10/6 21:07:40 拓冰建站 浏览量
Spring Boot + Vue美食网站开发实战:从零到一完整项目笔记 花了大概两周多的时间我把这个基于Spring Boot Vue的美食网站从零到一完整撸了一遍。做这个项目之前我正好在纠结怎么把前端Vue和后端Spring Boot这两块技术真正串起来——网上demo很多但大多数要么只讲后端接口要么只聊前端页面能一路从数据库设计做到部署上线的完整案例反而不多。所以我自己动手做了一整套从表结构、REST接口、鉴权登录到Vue路由、状态管理、跨域联调再到最后的打包部署全走了一遍。这篇就当作一份完整的项目笔记把那些“踩过才知道”的细节原原本本盘出来适合正在做毕设、准备面试项目、或者想系统练一遍前后端分离开发的读者参考。1. 为什么选Spring Boot Vue这套组合做美食网站1.1 先拆一下美食网站的核心需求很多同学拿到“美食网站”这种题目第一反应就是“随便做个列表页加个详情页就行了”等真正动手才发现需求没那么简单。我给自己定了一个能跑通全流程的功能边界而不是一味往大做前台用户端注册登录、菜品分类浏览、关键词搜索、菜品详情、加购物车、下单、订单查看、收藏菜品。后台管理端菜品CRUD、分类管理、订单状态处理、用户管理、轮播图配置。这个范围是可扩展的后面想做评论、评分、推荐算法都有现成的位置可以加。关键在于它覆盖了Web开发最常见的痛点场景登录鉴权、文件上传菜品图片、一对多关联查询分类-菜品、多表联查订单-订单项、状态流转订单待支付/已支付/已完成这些都是面试和实际工作中最常被问的东西。1.2 前后端分离到底解决了什么问题如果只是做一个课程作业用Spring Boot自带的Thymeleaf模板引擎直接在服务端渲染页面会更快但“基于Spring Boot Vue”的题目天然要求前后端分离。我一开始也觉得这在毕业设计里有点“为了分离而分离”真正做完之后反而觉得这个选择是对的。前后端分离最核心的价值在于前端只需要关心页面和交互通过Ajax请求后端接口拿数据后端只需要关心业务逻辑和接口返回值不用再纠结页面渲染这种事。这带来两个直接的好处一个是前端页面可以用Vue组件化组织菜品卡片、购物车、轮播图这些都能复用另一个是后端接口可以独立测试用Postman把接口全部打通了再连前端排查问题的时候能快速定位到底是前端没调对还是后端返回有问题。Vue和Spring Boot各自还有一层生态优势。Vue这边有Vue Router管页面跳转、Vuex或Pinia管全局状态比如用户登录态、购物车数量Spring Boot这边有Spring Security或JWT做鉴权、MyBatis管数据库操作、Spring Validation做参数校验。两边都是主流方案遇到问题网上随便一搜都有成熟的解决方案不像冷门技术栈卡住了半天找不到资料。1.3 技术栈版本选择与理由这是我在实际开发前花了不少时间确定的具体版本组合技术选型理由JDKJDK 8 或 11稳定Spring Boot 2.x完全支持面试用得最多Spring Boot2.7.x稳定版资料最多避开3.x的兼容坑MyBatis2.x starter自己写SQL灵活关联查询直观比JPA更可控数据库MySQL 8.x免费、生态好、遗留系统连接兼容性好前端框架Vue 2 Element UI 或 Vue 3 Element Plus看个人熟悉程度我用的Vue 2生态成熟稳构建工具Maven国内网络环境下比Gradle省心鉴权JWT 拦截器无状态、简单、不引入Spring Security的复杂度补充一句Spring Boot 3.x现在已经很成熟了但如果你是为了毕业设计或者面试项目去做我建议选2.7.x。原因很简单网上90%的资料和博客都是基于2.x写的遇到问题按图索骥更快3.x的jakarta命名空间改动和Spring Security 6的变化会白白消耗你本可以用在业务逻辑上的时间。2. 数据库设计美食网站最核心的家底2.1 表结构设计核心思路美食网站的数据模型不像电商那么复杂但该有的关系都得覆盖。我最终设计了七张核心表一张表负责一个业务域用户表id、username、password、nickname、avatar、phone、create_time分类表id、name、sort_order、status菜品表id、category_id、name、description、price、image、stock、status、create_time购物车表id、user_id、dish_id、quantity订单表id、order_no、user_id、total_amount、status、receiver_name、receiver_phone、address、create_time订单项表id、order_id、dish_id、dish_name、price、quantity收藏表id、user_id、dish_id、create_time这个设计里有一个很容易被忽略的点订单项表为什么要冗余一份dish_name和price字段因为菜品价格和名称是会被后台修改的如果订单项去关联菜品表实时查价格用户下单之后管理员改个价格订单历史就跟着变了这在业务上是不可接受的。订单一旦生成快照就必须固定下来。这也是一个很小但面试官很爱问的细节。分类和菜品是一对多关系在菜品表里放category_id外键用户和订单是一对多关系订单表里放user_id外键订单和订单项是一对多关系订单项表里放order_id外键。购物车和收藏都是用户和菜品多对多的“中间表”简化版。2.2 数据库连接配置与MyBatis的坑Spring Boot连MySQL的配置很简单但有几个参数值得多写几笔server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/food_website?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 10MB max-request-size: 10MBserverTimezoneAsia/Shanghai这个参数是我一开始没加结果时间字段出来全是“北京时间早上八点比数据库差八小时”的诡异情况。useUnicodetruecharacterEncodingutf8则是保证中文不乱码的标配不加的话菜品描述里的中文极易变成问号。MyBatis这边我直接用的注解加XML混合方式简单的单表操作用注解Select、Insert涉及关联查询和动态SQL就写在XML Mapper里。比如按分类查询菜品还需要连分类表拿分类名这种两表联查直接写XML更干净select idselectDishWithCategory resultTypecom.example.food.vo.DishVO SELECT d.id, d.name, d.price, d.image, d.description, c.name AS categoryName FROM dish d LEFT JOIN category c ON d.category_id c.id where if testcategoryId ! null AND d.category_id #{categoryId} /if if testkeyword ! null AND d.name LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY d.create_time DESC /select动态SQL这块是MyBatis最值钱的地方搜索关键词和分类过滤可以用同一个查询方法不用拆成两个接口。注意这里有第一个容易踩的坑MySQL的LIKE拼接不要直接写成%#{keyword}%MyBatis不会这样替换参数必须用CONCAT(%, #{keyword}, %)。这个坑我翻了半天源码才反应过来。2.3 统一响应体让前端少写一万个判断后端接口返回给前端的数据格式如果不统一前端每个请求都要猜“这次到底是对象还是数组还是错误信息”联调起来非常痛苦。我一开始就定义了一个统一的返回体Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }前端axios封装里只需要判断res.data.code 200即可所有异常统一在catch里处理。这个设计看起来简单但能让前后端联调效率提升好几倍尤其是你的前端是独立写的时候统一格式能避免“这个接口怎么一会儿返回数组一会儿返回对象”的困惑。3. 后端接口落地鉴权、菜品模块与文件上传3.1 注册登录模块选JWT而没有用Session的原因登录是每个项目的门面也是最容易做“假”的部分。我最终选择了JWTJSON Web Token方案。核心原因是前后端分离后后端接口是无状态的如果还用Session每次请求都要带着JSESSIONID去服务端查内存里的会话状态后端一重启所有登录态就没了扩展的时候还得做Session共享麻烦。JWT把用户信息加密签在Token里服务端不用存任何状态只要校验签名就能确认用户身份天然适配前后端分离。实现逻辑大概是这样的登录成功之后用jjwt库生成Token把用户ID和用户名放进去设置两小时的过期时间返回给前端保存到localStorage里。前端每次请求在axios拦截器里加上Authorization: Bearer ${token}。后端写一个拦截器拦截除登录注册外的所有接口校验Token的有效性从Token里解析出用户ID放到ThreadLocal或者直接通过HandlerArgumentResolver注入Controller参数。Component 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 )) { token token.substring(7); try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; } }这里有个特别容易踩的坑跨域请求会先发一个OPTIONS预检请求这个请求不带业务Token如果你在拦截器里直接拦截前端的每个请求都会被401打回去。所以必须像上面代码一样先放行OPTIONS方法。这个问题的排查一度让我怀疑人生最后发现是拦截器把预检请求一起拦了网上能搜到的帖子很多但说得都不是很清楚。3.2 菜品模块的分页、搜索与图片上传菜品列表是前台访问最频繁的接口必须做分页和控制返回字段。我用PageHelper做分页插件Controller层接收pageNum和pageSize参数Service层返回PageInfo统一封装GetMapping(/dish/list) public ResultPageInfoDishVO list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 8) Integer pageSize, RequestParam(required false) Long categoryId, RequestParam(required false) String keyword) { PageHelper.startPage(pageNum, pageSize); ListDishVO list dishService.queryDishList(categoryId, keyword); PageInfoDishVO pageInfo new PageInfo(list); return Result.success(pageInfo); }图片上传是菜品管理里的重头戏也是很多初学者做项目时最容易“看起来能跑但实际各种毛病”的地方。我是这样处理的前端用Element UI的el-upload组件选择图片把图片文件通过FormData发送到后端/upload接口后端用MultipartFile接收保存到服务器本地磁盘返回图片的访问URL。重点来了这个URL必须能被前端直接访问所以要么你在Spring Boot里配置静态资源映射把本地磁盘目录映射成虚拟路径要么把图片存到Nginx代理的静态目录。我采用的是在配置类里加资源映射的办法Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 磁盘路径 /home/ubuntu/food/images/ 对应访问路径 /images/** registry.addResourceHandler(/images/**) .addResourceLocations(file: /home/ubuntu/food/images/); } }这样前端拿到/images/xxx.jpg就能直接渲染。如果你不配置映射把图片存在项目内部的static目录可能当时能显示但打包成Jar之后写不进文件系统等到部署到服务器上就会出各种“图片上传成功但访问404”的诡异问题。3.3 购物车与订单事务和状态流转怎么处理购物车本身逻辑不复杂就是用户ID加菜品ID的增删改查加数量累加。但订单的生成涉及多张表的写操作创建订单、批量插入订单项、清空购物车、扣减库存这四步必须在一个事务里完成。我用Transactional注解保证了原子性——任何一步失败前面写入的数据全部回滚不然用户下单成功但购物车没清或者库存负数在业务上是灾难级的bug。库存扣减这块我推荐一个做法更新菜品表库存时加上stock 0条件用数据库层面的行锁防止超卖。虽然美食网站并发量不会很大但这个写法是生产级的标准思路写进项目文档里面试能加分Update(UPDATE dish SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}) int deductStock(Param(id) Long id, Param(quantity) Integer quantity); // 返回0说明库存不足抛出异常回滚事务 if (deductStock(dishId, quantity) 0) { throw new RuntimeException(库存不足); }订单状态我直接用一个Integer字段存储0待付款、1已付款/待发货、2已完成、3已取消。后台管理修改状态时加上状态机的校验逻辑比如只能从待付款流转到已付款或已取消防止前端调接口直接把状态改成完成。4. Vue前端从骨架到交互路由、请求封装与组件拆分4.1 项目初始化和目录结构的约定我用Vue CLI初始化前端项目命令是vue create food-web。这里有一个很容易忽略的环境细节安装依赖时建议用npm而且用国内镜像源否则npm install卡在node-sass这类二进制依赖上会让你怀疑人生。虽然没有必要展开讲镜像源的具体配置和网络环境相关但至少要知道如果安装依赖失败优先尝试换源后删除node_modules重新安装。目录结构我按模块划分而不是按页面划分src/ ├── api/ # 按模块封装的接口请求 │ ├── dish.js │ ├── order.js │ └── user.js ├── assets/ # 静态资源 ├── components/ # 公共组件 │ ├── DishCard.vue │ ├── CartBadge.vue │ └── SearchBar.vue ├── router/ # 路由配置 ├── store/ # Vuex状态管理 ├── utils/ # axios封装、工具函数 ├── views/ # 页面组件 │ ├── Home.vue │ ├── DishList.vue │ ├── DishDetail.vue │ ├── Cart.vue │ ├── Login.vue │ └── Admin │ ├── DishManage.vue │ └── OrderManage.vue └── App.vue这么分组的好处是新增一个接口时不需要去几十个文件里找应该写在哪儿按业务模块拆分的api文件每个页面调用起来也很直观。4.2 请求封装拦截器里统一处理Token和错误axios不封装直接用项目里会到处散落着重复的Token设置和错误提示代码。我是这样封装的基础请求实例import axios from axios import { Message } from element-ui const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动携带Token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) // 响应拦截器统一处理非200状态 service.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求出错) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } Message.error(error.message || 网络异常) return Promise.reject(error) } )这里有个设计细节值得注意我在请求拦截器去携Token、在响应拦截器去处理401把登录态失效后自动跳转登录页的逻辑收敛到了一处。这样业务代码里完全不用关心登录态只管拿数据。4.3 路由守卫与页面权限控制美食网站分了前台和后台两套页面后台管理页必须有登录态且是管理员才能访问。这在前端通过路由守卫实现而不是依赖后端拦截——后端拦截管的是接口安全前端守卫管的是页面访问体验两者缺一不可。const router new VueRouter({ routes: [ { path: /, component: Home, meta: { title: 首页 } }, { path: /dish/:id, component: DishDetail, meta: { title: 菜品详情 } }, { path: /cart, component: Cart, meta: { auth: true } }, { path: /admin, component: AdminLayout, meta: { auth: true, admin: true }, children: [...] }, { path: /login, component: Login } ] }) router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.auth !token) { next(/login) return } if (to.meta.admin) { const user JSON.parse(localStorage.getItem(user) || {}) if (user.role ! 1) { next(/) return } } next() })这个守卫逻辑支撑了“游客能看菜品登录用户能加购物车管理员才能进后台上传菜品”的权限模型。注意角色判断我用的本地存储里的user对象它是和后端返回的对齐的安全上不能只靠前端控制后端接口同样有管理员校验。4.4 关键组件菜品卡片、搜索防抖和购物车徽标菜品卡片是首页和列表页都会用到的公共组件我拆成了DishCard.vue接收一个dish对象展示图片、名称、价格提供“加入购物车”的事件。图片加载失败的处理也放在了组件内部——用error事件替换成一张占位图这个能避开很多线上图片404的尴尬场景。搜索框我做了防抖处理用户停止输入0.5秒后才发起请求。不做防抖的话用户输入一个关键词会连续触发五六次接口请求浪费带宽不说还会出现前后请求返回顺序不一致、旧结果覆盖新结果的问题import { debounce } from lodash methods: { handleSearch: debounce(function() { this.$router.push({ path: /dish/list, query: { keyword: this.keyword } }) }, 500) }购物车徽标是用Vuex管理购物车总数量的典型场景。加入购物车、修改数量、删除、清空这四种操作都同步提交到Vuex的mutation里购物车数量在Header组件里通过mapGetters取出来显示。刷新页面后从后端重新拉取购物车列表保证前端状态和后端数据保持一致。5. 联调、打包与部署文档里不会写的那些坑5.1 开发环境的跨域配置用Proxy而不是零散的CORS头前后端分离开发时前端跑在localhost:8080后端跑在localhost:8081或8080端口不同浏览器就会报跨域错误。我在Vue CLI的vue.config.js里配置了devServer代理把前端的/api前缀代理到后端地址这样浏览器视角看所有请求都是同源的const { defineConfig } require(vue/cli-service) module.exports defineConfig({ devServer: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } })这里有个特别容易搞混的细节我的后端接口路径是没有/api前缀的前端请求写在baseURL: /api代理转发时通过pathRewrite把/api去掉再转发给后端。如果你后端Controller写的是/api/dish/list那pathRewrite就不能去掉否则路径对不上返回404。很多联调半天不通的问题最后查下来都是这种前缀没对齐。5.2 打包发布两种部署方式的选择打包发布我先后试过两种方式。第一种是把Vue打包后的dist目录扔到Spring Boot的src/main/resources/static下重新打成Jar包这样Spring Boot同时提供静态页面和接口。这种方式胜在部署简单适合毕设演示和中小型个人项目。但后台修改菜品图片时有个隐患如果前端页面和后端接口在同一个Origin下没有跨域问题但图片文件如果存在服务器磁盘而非Jar包内路径映射在Jar里需要额外配置。第二种方式是前后端彻底分离部署后端Spring Boot打包成Jar运行在8080端口前端npm run build后部署到NginxNginx监听80端口并代理/api到后端。这种方式更适合真实项目分离更彻底但多一个Nginx配置步骤server { listen 80; server_name localhost; # 前端静态文件 location / { root /usr/share/nginx/html/food-web/dist; index index.html; try_files $uri $uri/ /index.html; # 解决Vue路由刷新404 } # 后端接口代理 location /api { proxy_pass http://localhost:8080/api; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里面最值得记住的是try_files $uri $uri/ /index.html这一行。如果你不做这个配置Vue Router用history模式时直接访问/cart刷新页面就会404——因为Nginx去找服务器上对应的cart.html根本不存在。加了try_files之后所有未知路径都回退到index.html由前端路由接管。5.3 实测过程中遇到的高频问题排查清单我把整个项目联调过程中遇到频率最高的问题整理成了一张排查清单给正在做类似项目的人直接对照现象大概率原因处理方式前端请求一直401拦截器拦了OPTIONS预检请求放行preHandle中的OPTIONS请求中文乱码数据库连接串没配characterEncoding加到jdbc.url参数里上传图片后刷新404没有配置磁盘路径资源映射加WebMvcConfigurer映射前端改了数据后页面不刷新没有用Vuex响应式状态全局状态用store管理不要散落组件里打包后Vue路由刷新404Nginx没有try_files回退加try_files $uri $uri/ /index.html时间字段比数据库差8小时数据库连接串没配serverTimezone加serverTimezoneAsia/Shanghai下载依赖失败卡住npm依赖下载问题换镜像后删除node_modules重装这张表看着简单但每一行都是我实际花了一两个小时甚至更久才定位出来的。尤其是401那个问题前后端都检查遍了最后才发现是拦截器把预检请求误伤了——这个经验写下来能帮后来者省一整晚的排查时间。5.4 管理员后台表格、表单校验和图片上传组件管理员后台我主要做了菜品管理和订单管理两个页面。菜品管理用Element UI的el-table展示菜品列表支持搜索、分页点“编辑”弹出el-dialog框里面是el-form表单带图片上传。这里分享一个表单校验的实用姿势数字类型的价格字段我在表单里用el-input-number校验规则里设置精度为2确保只能保留两位小数价格在前端展示时再用toFixed(2)格式化避免0.10.2那种浮点精度问题直接暴露给用户。图片上传组件那块我封装了一个ImageUpload.vue内部用el-upload配合action属性指到后端接口。上传成功之后把返回的URL回填给表单的image字段提交时随表单一起发到后端。预检测文件类型我做了jpg/png限制大小限制10MB避免用户传一个几十兆的高清原图把磁盘撑爆。要注意el-upload组件默认会在onSuccess回调里拿到响应数据但如果你在axios拦截器里统一解包了一层那这里拿到的就应该是被解包后的数据别取错层级。6. 项目复盘这套代码还能怎么继续扩展6.1 从架构角度审视自己写的代码两周的项目做完回头看代码我觉得最有价值的成长点是理解了分层架构在真实项目里是怎么约束人的。我的后端结构分了Controller、Service、Mapper三层Controller只负责收参数、调Service、返回结果Service只负责业务逻辑比如下单事务、库存扣减Mapper只负责SQL操作数据库数据。强制按层写代码初期会觉得麻烦但后期加功能的时候真香——比如加一个“销量排行”接口只需要在Mapper加一条SQL、Service加一个方法、Controller加一个路由不影响任何已有功能。有一个分层没做到位的反面例子最开始我图省事在Controller里直接写SQL查询相关逻辑后来要复用同一套查询逻辑的时候就尴尬了只能重构。所以给正在做项目的读者一个建议从第一个接口开始就遵守Controller-Service-Mapper三层写法不要为了省几行代码把路走窄了。6.2 推荐的功能扩展方向这个美食网站的基础框架搭好之后扩展什么功能都有空地。我整理了几个方向按性价比排序菜品推荐基于用户收藏或下单记录做简单推荐。用MyBatis查用户看过/买过的分类推荐同分类高分菜品不需要引入太高深的算法就能明显提升项目亮点。评论与评分在菜品表旁边挂一张评论表前台菜品详情页展示评论列表后台可以删除违规评论。这个功能对电商、O2O类项目来说是刚需。接入支付模拟订单增加支付宝/微信扫码支付的按钮哪怕只是跳转到支付成功页也能把订单流程补全得更完整。并发优化把商品库存热点数据放进Redis用Redis的DECR命令做扣减扛高并发的同时配合数据库最终一致。这个方向作为面试谈资非常加分。用Pinia替换Vuex如果起步就用Vue 3主流状态管理已经切换到了Pinia代码更简洁TypeScript支持更好。6.3 我个人做完这个项目最实在的三条体会第一先画完数据库表再做代码真的能省一半时间。我第一版是边写代码边加表结果改了三遍表结构代码也跟着返工。第二接口文档无论多简陋都要同步写。我用Apifox管理接口前端同学哪怕就是自己照着文档调接口比翻Controller代码高不知道多少倍效率。第三不要怕踩坑但一定要记录坑。本文里那张排除清单正是这半个月最值钱的产出。要说下一个版本最想做什么我可能会给推荐模块加一个基于用户行为的简单召回用Spring Boot加Redis把热榜做起来。这个项目做完最直接的感受是后端管好数据和业务规则前端管好交互和状态两者用统一接口协议连接起来这就是现代Web开发最核心的骨架。剩下的就是在这个骨架上持续堆肉了。