带毕业设计这些年,SpringBoot + Vue这个组合我看了不下几十个项目。今天聊的美食推荐商城,后端是Java + SpringBoot + MySQL + MyBatis,前端是Vue全家桶,属于那种“你认真做完、答辩能讲清楚、简历也敢写出来”的典型全栈系统。我之前亲自搭过一遍,踩坑踩到凌晨三点的经历还历历在目,这篇干脆把设计思路、库表结构、核心实现和联调部署全部摊开说清楚,给正在做类似项目的人一个参考。
这个项目能做什么?简单说就是一套前后端分离的B2C商城:普通用户注册登录、浏览首页推荐、按分类筛选菜品、查看详情、加入购物车、下单;管理员后台负责维护分类、上下架菜品、处理订单状态。它适合两类人看:一是拿它做毕业设计或课程项目的同学,二是想系统梳理一遍SpringBoot + Vue全栈开发思路的初级开发者。无论你是刚接触SpringBoot,还是已经会写点接口但不知道怎么串成完整项目,下面这些内容都可以直接“抄作业”,但更重要的是理解每个选择背后的理由。
1. 为什么是SpringBoot + Vue + MyBatis这套组合
1.1 从SSM到SpringBoot,省下来的不只是配置
我最早学Spring的时候还在用SSM:Spring + SpringMVC + MyBatis,最痛苦的是那堆XML配置——数据源配置、事务配置、扫描配置、视图解析器配置,每加一个功能都要去翻配置文件。SpringBoot出来以后,最直观的感受就是“项目能跑了”:内嵌Tomcat让java -jar直接启动,spring-boot-starter-web把依赖管理简化了一大截,自动配置帮我处理掉大量样板代码。
对于美食推荐商城这种中小型业务系统,SpringBoot带来的最大收益是开发效率。想象一下,你明明想写用户登录、菜品推荐、购物车这些业务逻辑,结果前两周全在跟配置搏斗,那是很挫败的。SpringBoot不是没有缺点,但在短周期项目、单人开发、以业务展示为核心的场景下,它几乎是最优解。
另外还有一点:SpringBoot生态里的集成方案太成熟了。连MyBatis、分页插件、JWT这些都有专门的starter,依赖一引、配置一写,马上就跑起来。相比之下,手动搭SSM环境光导入jar包就可能因为版本冲突搞得头大。做项目时间本来就很宝贵,能省则省。
1.2 前后端分离的价值:Vue到底做了什么
很多人在选型时会纠结:要不要用Thymeleaf?要不要继续用JSP?我的判断是,如果项目重点是“完成一个完整业务系统”而不是“证明你会服务端渲染”,Vue是更舒服的选择。
美食推荐商城本质上充满了列表页、详情页、购物车交互。如果走服务端渲染,用户点一下加购、切一下Tab,页面就得刷新,还要自己写一堆Ajax + 模板拼接,代码一旦多起来,找bug都费劲。Vue的双向绑定和组件化让交互逻辑变得非常直接:购物车数量加减,改data里的值,页面自动更新;菜品列表复用同一个卡片组件,数据一变,视图跟着变。
我建议前端用Vue 2 + Element UI,原因是这个组合的生态和中文资料实在太成熟了,遇到任何问题基本都有现成答案。Vue 3 + Element Plus当然也行,但如果你不是对Composition API很熟,毕设阶段选Vue 2能把踩坑成本降到最低。等这个项目做好了,再回头刷Vue 3的知识点也不迟。
1.3 MyBatis和MySQL的组合逻辑,为什么不是JPA
持久层选MyBatis,很大一个原因是SQL可控可优化。美食商城里“按分类查热门菜品”“统计用户最近访问过的分类”“订单状态分页查询”,这些多少带点业务逻辑的查询,用MyBatis手写SQL我可以清楚知道数据库到底执行了什么。Spring Data JPA虽然也很优秀,但对新手来说,它的方法命名规范和自动关联查询一旦出问题,排查起来比写SQL慢得多。
MySQL的选择理由就更直接了:免费、普遍、省钱省事。你未来的面试官、导师、同事大概率都熟悉它,沟通成本低。版本建议直接上8.0以上,UTF-8字符集、JSON类型、窗口函数都有更好的支持。
持久层还有一个容易踩的坑——MyBatis的缓存机制。一级缓存默认开启,同一个SqlSession里重复查询同一SQL会走缓存;二级缓存需要手动配置,涉及序列化,而且不适合缓存复杂查询结果。我的建议是:这个项目阶段不要开二级缓存,等真正遇到性能瓶颈再考虑。缓存机制本身可以作为面试谈资,但项目里不用不代表不懂,你要能说清楚它存在的意义。
1.4 版本组合怎么定才不折腾
我实测比较稳的一套版本组合是:JDK 1.8 + Spring Boot 2.7.x + MyBatis 3.5.x + MySQL 8.0 + Vue 2.7.x + axios。前端脚手架用Vue CLI,UI库用Element UI。
为什么不用Spring Boot 3.x?因为3.x的包名从javax迁移到了jakarta,网上大量教程还在写旧包名,跟着做很容易遇到莫名其妙的编译错误。如果你的项目需要用到某个新特性,那另说;但做美食推荐商城这种业务系统,2.7.x完全够用,资料最多、坑最少。做人要务实,做技术选型也一样。
2. 数据库设计:先把表和关系定死,再谈功能
2.1 先想清楚系统里有哪些角色和业务流程
我习惯写代码之前,先拿纸把业务流程画出来。这个美食推荐商城的核心角色有三类:普通用户、管理员(如果要做商家下单发货,也可以抽象出商家角色,但毕设里管理员兼任完全足够)。
核心流程是:用户注册登录 → 浏览首页推荐菜品 → 按分类查看菜品列表 → 查看菜品详情 → 加入购物车 → 提交订单 → 管理员在后台维护分类与菜品、修改订单状态。
把这个流程理清后,你就会发现数据库表其实就那么几张:用户表、分类表、菜品表、购物车表、订单表、订单明细表,最多加一张推荐记录表。先把主骨架定好,后面所有功能都是在这个基础上加字段、加关联,而不是推倒重建。我见过很多同学一上来就设计二十多张表,最后自己都分不清每张表是干嘛的,完全没有必要。
2.2 核心表结构:照着这个思路设计,踩坑最少
我直接把表结构的设计思路列出来,字段名可以按自己习惯调整,但核心思想一定要有。
user用户表
- id BIGINT 主键自增
- username VARCHAR(50) 唯一登录名
- password VARCHAR(255) 存BCrypt加密后的值
- nickname VARCHAR(50) 昵称
- avatar VARCHAR(200) 头像地址
- role TINYINT 0普通用户,1管理员
- create_time DATETIME
密码这件事我不多说,但一定要强调:别存明文。哪怕只是毕设,从表结构就能看出你有没有基本的安全意识。
category分类表
- id BIGINT
- name VARCHAR(50)
- sort INT 排序权重
- create_time DATETIME
菜品和分类是多对一关系,所以只需要在dish表里存category_id,不需要单独建关联表。
dish菜品表
- id BIGINT
- category_id BIGINT
- name VARCHAR(100)
- description VARCHAR(500)
- price INT 以“分”为单位
- image VARCHAR(200)
- sales INT 销量
- stock INT 库存
- status TINYINT 1上架,0下架
- recommend TINYINT 是否推荐
- create_time DATETIME
price用整数存“分”而不是浮点数,这个习惯能避免掉很多金额计算误差,工作中电商系统也都是这么做的。
cart购物车表
- id BIGINT
- user_id BIGINT
- dish_id BIGINT
- quantity INT
- checked TINYINT
- create_time DATETIME
购物车设计我推荐在(user_id, dish_id)上建联合唯一索引,插入时用INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + 1,这样同一个用户对同一菜品只会有一条记录,数量累加。
order_info订单表
- id BIGINT
- order_no VARCHAR(64) 唯一订单号
- user_id BIGINT
- total_amount INT
- status TINYINT 0待支付,1已支付,2已发货,3已完成,4已取消
- create_time DATETIME
- pay_time DATETIME
- deliver_time DATETIME
order_no建议生成规则用时间戳加随机数,并且建唯一索引。订单状态用整数枚举,比用字符串省空间,也更容易用代码控制状态流转。
order_item订单明细表
- id BIGINT
- order_id BIGINT
- dish_id BIGINT
- dish_name VARCHAR(100)
- dish_image VARCHAR(200)
- price INT 下单时的价格快照
- quantity INT
注意,明细表里要冗余存储菜品名称和图片。为什么?因为之后菜品可能改名、换图、改价,如果明细表不存快照,订单历史记录就跟着跟着变了,这在电商业务里是不可接受的。
recommend_log推荐记录表
- id BIGINT
- user_id BIGINT
- dish_id BIGINT
- source VARCHAR(20) 推荐来源:hot/category/random
- create_time DATETIME
这张表的价值在于:它能帮你讲清楚推荐逻辑,也能给你后续做推荐效果分析留下数据依据。答辩时你可以直接打开这张表说“我的推荐不是随机刷的,每一步都记录了来源”,这个加分效果很好。
2.3 索引、外键和字符集的细节
外键我建议在数据库设计文档里体现,但建表时不要到处加FOREIGN KEY。物理外键会拖慢写入性能,而且删除数据时容易引发连锁约束错误。实际开发中更常见的是逻辑外键,靠程序保证数据完整性,比如下订单时先去查菜品是否存在、状态是否上架。
索引方面,user表的username加唯一索引,order_info表的order_no加唯一索引,order_info表的user_id加普通索引,cart表的(user_id, dish_id)加联合唯一索引,dish表的category_id加普通索引。这些索引覆盖了登录、查询订单、查购物车、按分类查菜品这些高频场景,性价比很高。
字符集方面,所有表统一使用utf8mb4,排序规则用utf8mb4_general_ci。如果建表时用了默认的latin1或者utf8mb3,后面接口返回中文变问号的概率极高。这个坑极其隐蔽——前端看着是好的,数据库存进去的全是乱码,排查半天还找不到原因。
3. 后端核心实现:认证、推荐、下单和分页
3.1 JWT登录认证和拦截器,轻量又够用
这个项目我没有上Spring Security,而是用JWT + 自定义拦截器实现登录认证。理由是:业务里只有普通用户和管理员两种角色,没有复杂权限树,上Spring Security反而增加配置负担和学习曲线,毕设阶段自定义拦截器完全够用,而且能让你更清楚地理解认证流程。
具体实现分三步:
第一步,登录接口校验用户名密码。密码用BCrypt加密比对,通过后生成JWT,把userId和role放进去作为payload,过期时间设置2到4小时。
第二步,前端拿到token后存localStorage,每次axios请求在请求拦截器里加Authorization: Bearer <token>。
第三步,后端写一个AuthInterceptor,在WebMvcConfigurer里注册并拦截所有/api/**请求。拦截器中先从Header取出token,解析出userId放进ThreadLocal或Request属性,供后续Controller使用。同时放行登录、注册、公开的菜品查询接口。
管理员角色判断我推荐用自定义注解@RequireAdmin,在拦截器里读取token中的role,非管理员直接返回403。这样比在Controller里到处写if判断优雅得多。
3.2 美食推荐怎么实现,才能不显得“很水”
推荐功能是项目名字里的关键词,这部分如果只是SELECT * FROM dish LIMIT 10,答辩肯定站不住脚。我采用的方案是混合推荐,设计逻辑在保证完成度的基础上足够清晰。
简单说就是三个层次:
- 基于用户行为的推荐:用户浏览或加购菜品时,记录这些菜品所属的分类ID,统计出现次数最多的分类,然后推荐该分类下销量高、状态上架的菜品。
- 基于热度的兜底推荐:全局按销量或浏览量排序,推荐热门菜品。适合没有行为数据的新用户。
- 冷启动处理:新用户没有任何记录时,直接推荐各分类下的热门菜品,保证列表不空白。
我在代码里用一个RecommendService统一封装:接收userId,先去recommend_log或行为表查历史,如果该用户访问过某些分类,就查对应分类的热门菜;查不到行为数据就走全局热度查询。最终把推荐的来源写到recommend_log表里,字段如hot、category、random。
这套实现不炫技,但它回答了“为什么这样推荐”和“怎么评价推荐效果”两个追问。如果你还有余力,可以在内存里实现一个最简单的协同过滤思路:找和当前用户历史分类偏好相似的用户,推荐他们都买过的菜品。数据量不大时效果也挺好,面试时能当亮点讲。
3.3 下单和库存的事务一致性,别在这个环节翻车
购物车提交订单是典型的多表操作:创建订单主表、创建订单明细、扣减菜品库存、清空购物车。这个流程必须在一个事务里,否则插入订单成功但扣库存失败,或者购物车没清空,系统整体就乱套了。
SpringBoot的实现方式是在Service层方法上加@Transactional,默认遇到RuntimeException就回滚。这里有几个容易踩的点:
- 事务要加在Service方法上,不要加在Controller方法上。
- 方法内部不要随意try-catch吞掉异常,否则事务不会感知到错误,自然也不会回滚。如果确实要捕获,捕获后要手动
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),或者干脆重新抛出。 - 自调用问题要注意:同一个类里方法A调方法B,如果B上有@Transactional,A没加,B的注解不生效。因为Spring事务是通过代理实现的,内部调用不会走代理。把调用入口方法也加上事务即可。
扣减库存我用的是UPDATE dish SET stock = stock - 1 WHERE id = ? AND stock > 0,这样靠数据库行锁和条件判断就能避免超卖。如果有并发并发用户同时抢最后一份菜,只有一个请求能更新成功,其他请求影响行数为0,程序里判断影响行数后抛异常回滚即可。这段代码不算多,但面试官问“高并发下怎么防止超卖”时,你能答出这句,就已经超过大多数人了。
3.4 MyBatis分页插件,用法和坑一次说清
后台管理页面里菜品列表、订单列表都必须分页,我用的是PageHelper。依赖只需要引入一个starter:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>核心用法是查询前调用PageHelper.startPage(pageNum, pageSize),紧接着第一条MyBatis查询会被拦截并自动拼接LIMIT:
PageHelper.startPage(pageNum, pageSize); List<Dish> dishList = dishMapper.selectByCondition(categoryId, keyword); PageInfo<Dish> pageInfo = new PageInfo<>(dishList);返回给前端的数据用pageInfo.getList()、pageInfo.getTotal()、pageInfo.getPages()这几个字段组装即可。需要注意两点:
startPage之后必须紧跟你要分页的那条Mapper查询,中间不能插入其他查询、循环、线程操作,否则分页可能作用到错误的SQL上。- PageHelper的线程安全是依赖ThreadLocal实现的,用完一次后会自动清理,但如果你在异步线程里用,分页信息会丢,要注意。
遇到“分页没生效”的情况,不要慌,先打开MyBatis的SQL日志打印,看看PageInterceptor到底有没有拦截到那条SQL。很多时候是结果映射里嵌套了其他查询,导致分页被干扰。日志一开,问题马上就清楚。
4. 前端Vue部分:从脚手架到可交付页面
4.1 项目初始化和依赖安装
前端我推荐用Vue CLI先初始化项目,虽然Vite更快,但Vue CLI在文档和插件生态上更省心。命令很简单:
vue create food-mall-front创建的时候勾选Router和Vuex,然后进入项目安装axios和Element UI:
npm i axios element-ui -S这里有个高频坑:node-sass装不上或编译报错。遇到这种问题不要死磕版本,直接把node-sass移除,改用sass和dart-sass编译。前端工具链的版本兼容问题,解决办法永远是“换一个解法”而不是“硬解”。
目录结构我习惯分成api、assets、components、router、store、utils、views。api目录里按后端模块拆文件,比如dish.js、cart.js、order.js,每个文件导出多个请求函数,页面里只调用函数,不在组件里堆axios逻辑。这样联调阶段接口地址变动时,只需要改api文件,排查问题也快。
4.2 路由配置和登录守卫
路由层面,登录页、注册页和首页公开访问,其他页面需要登录。我在路由meta里加一个requiresAuth字段,然后用beforeEach守卫统一拦截:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })为什么要前端也拦一遍?虽然后端拦截器会拦住未登录请求,但前端先判断能避免用户完全没感知地跳到空白页,还能省掉一次无效请求。登录成功后,再根据用户角色跳转到首页或后台管理页。
4.3 axios封装和跨域代理,一次配好
axios封装的核心是拦截器。请求拦截器统一加token,响应拦截器统一处理后端返回的{ code, msg, data }结构,非200状态码自动弹出提示。
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res.data }, error => { if (error.response && error.response.status === 401) { router.push('/login') } return Promise.reject(error) } )跨域问题在本地开发阶段直接用vue.config.js配置代理解决:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }前端所以请求都写/api/xxx,完全不要拼http://localhost:8080,否则代理无效。用了代理之后,浏览器看到的请求仍然是同源的,就不存在跨域报错。
4.4 核心页面和组件的设计思路
首页是推荐商城的门面,我拆成三个组件:顶部搜索栏、分类Tab、菜品卡片列表。菜品卡片组件DishCard接收dish对象,展示图片、名称、价格和销量,点击后跳转到详情页。这里提一下Vue 2里最容易踩的响应式坑:给数组某个下标直接赋值,界面不会更新。必须用this.$set(this.list, index, val)或者改用splice方法。
菜品详情页除了展示信息,还负责“加入购物车”的操作。购物车状态我建议放Vuex,因为首页、详情页、导航栏角标、购物车页都要读取它,放组件里来回传props太容易出错。
订单提交流程要处理loading状态。提交按钮在请求期间置为不可点击,防止用户重复点击生成多个订单。这个细节虽小,但演示时出现重复订单是非常扣分的。
4.5 前端才是隐藏坑的最大来源
说实话这个项目里前端踩的坑总数比后端还多。除了上面说的数组响应式,常见还有三种:
- vue-router开启history模式后,部署到Tomcat或Nginx时刷新页面404。省心方案是直接改成hash模式,地址带个#不好看,但不会出错。如果非要history,需要配置后端
try_files。 - 图片显示不出来,大概率是后端返回的是相对路径,前端拼接时少了baseURL。建议在axios里配一个全局
VUE_APP_BASE_URL,图片地址统一用这个常量拼接。 - Element UI按需引入和全量引入的差异。为了省事,开发阶段我直接全量引入,写起来带劲,打包体积大点也无所谓,毕竟目标是先把项目跑起来。
5. 调试、联调和部署踩坑记录
5.1 本地联调最大的坑:还是跨域
就算前端配好了代理,联调时还是容易遇到各种跨域相关的问题,尤其是直接在浏览器里打开静态文件调试时。我的建议是开发阶段始终通过npm run serve启动前端,不要双击index.html去开。
生产部署时如果后端单独跑8080,前端dist用Nginx托管,一定要配置反向代理:
location /api/ { proxy_pass http://localhost:8080; }这样浏览器访问的是Nginx的80或443端口,后端API在同域下,不存在跨域。如果只是临时演示,后端开启CORS也是一条路,但生产环境还是反向代理更规范。
5.2 MyBatis返回null字段和日期格式问题
这个坑出现的频率极高:接口返回的数据里createTime是null,但数据库明明有值。原因通常是MyBatis没有开启驼峰映射。在application.yml里加一行:
mybatis: configuration: map-underscore-to-camel-case: truecreate_time才能自动映射到createTime。没有这行配置,MyBatis只会把你指定的resultMap里的字段匹配上,其他字段全是null。
日期格式建议统一在后端处理。在配置里加上:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8或者在具体时间字段上用@JsonFormat注解。如果前后端各处理各的日期格式,联调时会发现前端拿到的时间戳和自己本地时区差8小时,又是一顿排查。
5.3 图片上传和静态资源映射,部署后最容易“失联”
菜品图片上传是一个隐藏门槛。后端Controller接收MultipartFile,把文件写到服务器某个本地目录,比如/data/food-mall/upload/。然后写一个配置类,把请求路径/images/**映射到这个本地目录:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourceHandler("file:/data/food-mall/upload/"); }数据库里存的是/images/dish/xxx.jpg这种相对路径,前端直接拼接后端地址访问。
这里有一个致命的坑:千万不要把上传的文件写到SpringBoot项目的classpath里,比如src/main/resources/static/upload。开发环境中没问题,一旦打包成jar运行,这个路径是只读的,图片要么丢要么写不进去。我见过好几个人开发时一切正常,部署后图片全挂,最后才发现是这个问题。
5.4 打包部署的经典步骤
后端打包用Maven:
mvn clean package生成jar后运行:
java -jar food-mall-server.jar端口冲突时可以加--server.port=8081临时改端口。前端打包:
npm run build生成dist目录。本地快速预览可以用npx serve dist,但正式演示最好放到Nginx的html目录下,访问速度和稳定性都好很多。
部署后最常见的错误是接口404或200但返回空数据,原因往往只有一个:前端请求的API地址还是localhost或写死成了开发环境的IP。打包前记得把api文件里的baseURL改成实际的后端地址,或者直接用相对路径/api,让Nginx代理统一转发。
5.5 演示和答辩的演示顺序,决定项目观感
如果你做的是毕设或者面试项目,演示顺序很重要。我一般会建议按这个顺序走:
- 先演示注册、登录,登录成功后页面跳转。
- 展示首页推荐列表,顺口说明推荐逻辑的三个来源。
- 进分类列表,演示筛选和分页。
- 把一道菜加入购物车,去购物车调整数量,提交订单。
- 管理员登录,演示菜品上下架、订单状态修改。
- 最后打开数据库,展示订单表、明细表的数据变化,并把状态字段对应上,证明数据闭环。
哪怕项目再朴素,这个流程走完之后,对方会觉得这个系统是真实完整的,而不是只做了几个静态页面。
6. 如果能重做一遍,我会怎么改
6.1 先把Redis缓存补上
这个项目如果只是毕设,不引入Redis完全能过。但如果你有余力,我建议给推荐列表加一层Redis缓存,key可以设计成recommend:user:{userId},设置5到10分钟过期。这样做的收获不只是性能提升,更重要的是你可以在答辩时讲清楚“缓存穿透、缓存击穿、缓存雪崩”这几个点分别怎么应对,这在后端面试里出现频率极高。
6.2 MyBatis-Plus可以省掉一半样板代码
手写BaseMapper和通用CRUD确实练基本功,但MyBatis-Plus能把这些样板代码的时间省下来。分页用内置的IPage,条件构造器几乎能覆盖所有列表查询。你先把原生MyBatis跑通一遍,理解SQL和映射的本质,再用Plus,就会觉得它是真香而不是黑魔法。
6.3 推荐逻辑的下一步升级方向
文章里实现的混合推荐只是最朴素的版本。继续往上走,可以设计一个按用户行为加权的标签体系,给用户贴上“喜欢辣”“偏爱甜品”这类标签,然后根据菜品标签匹配推荐。再往上还有基于协同过滤的算法,但做之前要想清楚:数据量小的时候,复杂算法的效果不一定比简单规则好,甚至可能因为冷启动问题推荐得更差。先把简单的跑通,再谈优化。
这个美食推荐商城项目做到这里,设计、实现、调试和部署的基本链路就完整了。我个人的体会是:做一个项目,最重要的不是一开始就把技术栈堆满,而是先把业务流程想清楚,把一个功能闭环老老实实测通,再去加缓存、加算法、加花活。如果你正在为这个项目熬夜,建议从数据库设计开始,一步一个脚印地走,别急着敲代码。