几个月前有个学弟在后台问我想拿一个美食网站当毕业设计让我推荐后端框架。他纠结了很久是用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、statusfood_category菜品分类表id、name、sortfood菜品表id、category_id、name、description、price、image、stock、sales、statusfood_collect收藏表id、user_id、food_id、create_timecart_item购物车表id、user_id、food_id、quantity、selectedfood_comment评论表id、user_id、food_id、content、rating、create_timeorder_info订单表id、order_no、user_id、total_price、status、create_time、pay_timeorder_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, ListCartItemDTO cartItems) { // 1. 计算订单总价校验库存 BigDecimal totalPrice BigDecimal.ZERO; ListOrderItem 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 vitelatest food-web -- --template vue cd food-web # 安装基础依赖 npm install vue-router4 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-rowel-col布局里。卡片组件大致长这样template el-card :body-style{ padding: 0px } classfood-card img :srcfood.image classfood-image clickgoDetail(food.id) / div classfood-info h3{{ food.name }}/h3 p classdesc{{ food.description }}/p div classprice-line span classprice{{ food.price }}/span span已售 {{ food.sales }}/span /div el-button typeprimary sizesmall clickaddCart(food.id) iconShoppingCart 加入购物车 /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/loginVite 开发服务器会把它转发为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;charsetutf-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } }然后注册进 WebMvcConfigurerConfiguration 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服务有没有启动。这里我要着重说一句serverTimezoneAsia/Shanghai这个参数最好加上否则服务器和本地时区不一致日期存进数据库后读出来会差8个小时。spring: datasource: url: jdbc:mysql://localhost:3306/food_website?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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鉴权这些知识换一个业务场景照样能用得上。