SpringBoot+Vue美食推荐系统全解析:从数据库到推荐算法落地
刚开始接触“基于SpringBootVue的美食信息推荐系统管理系统”这个题目时我以为是又一个普通的管理后台模板真正动手做下来才发现它比增删改查有意思得多既要处理常规的用户、商家、菜品维护还要把“推荐”这件事从需求翻译成数据表、查询SQL和前端交互逻辑。写完这套系统之后我的体会是它非常适合作为前后端分离练手的完整项目尤其是如果你想在简历上写一个“不是纯CRUD”的项目美食推荐比图书管理、新闻发布更有说服力。这篇文章我会把整个项目从需求拆解、数据库设计、MyBatis持久层搭建、后端推荐逻辑、Vue前端交互到部署踩坑的全部过程铺开讲。适合正在做毕业设计、求职项目或者刚学完SpringBoot和Vue但不知道怎么把两者整合成完整系统的读者。默认你已经有Java基础和Vue基础下面直接进入正题。1. 需求拆解与技术选型把“推荐”翻译成可落地的功能模块1.1 这个系统的核心不是展示美食列表而是“千人千面”很多人在设计美食信息推荐系统时第一反应是做一个美食列表页加个分类筛选、搜索框后台能维护菜品数据就觉得完事了。但“推荐”才是这个题目的灵魂。推荐的意思是不同用户打开首页看到的内容不一样。普通列表页只按分类、价格、时间排序展示所有用户看到的是一样的结果推荐系统则需要根据用户的历史行为计算兴趣度。这里的用户行为分两种一是显式反馈比如给菜品打星级评分、写文字评价二是隐式反馈比如收藏了某道菜、反复浏览某道菜。个人项目里最容易采集的就是评分和收藏所以数据库设计必须围绕“用户—美食—行为”这三个实体来展开。项目里我分为两个角色普通用户和管理员。用户端要能看到首页推荐流、按分类浏览、查看美食详情、给美食打分、收藏美食管理员后台要能维护美食信息、管理分类、处理用户状态。这个设计虽然不复杂但已经能覆盖一个推荐系统的基本闭环用户产生行为、行为支撑推荐、推荐反过来促进更多行为。1.2 技术选型为什么是这一套选型这件事我建议不要盲目追新而是看这套组合能否覆盖项目需求、能否让你在开发过程中把核心知识练扎实。我的选择是基于SpringBootVueMyBatisMySQL理由如下技术承担的角色选型理由SpringBoot后端接口框架、业务逻辑承载内置Tomcat起步快生态成熟适合快速搭建RESTful APIMyBatis数据持久层推荐逻辑依赖多表联查和动态条件XML方式对SQL控制更精确MySQL数据存储关系型数据模型清晰用户、美食、评分的关系用外键和索引非常好管理Vue前端展示与交互组件化开发首页推荐流、详情页、后台管理都能拆成独立组件开发效率高为什么不选更重的框架我个人认为这个项目的数据量级在万级以下推荐逻辑完全可以在SQL层面完成不需要上Spark、Mahout、ES那一套。微服务更是没必要。把SpringBoot、MyBatis、MySQL和Vue这四样吃透比追求全家桶对个人成长帮助更大。1.3 功能模块清单用户端和管理端各管哪些事我整理了一份最终实现的功能清单照着这个清单开发不会漏功能用户端注册登录、首页推荐流热门推荐猜你喜欢、按分类筛选美食、关键字搜索、美食详情、星级评分与评论、收藏与取消收藏、个人中心查看我的评分和收藏。管理端美食信息增删改、美食分类维护、用户列表与禁用/启用、评论列表管理。接口统一使用/api前缀RESTful风格管理员接口在路径上加/admin区分登录鉴权用JWT实现。接下来从数据库开始逐步拆解每个模块的实现细节。2. 数据库设计与MyBatis持久层先让表能撑起推荐逻辑2.1 五张核心表的建表语句与设计思路数据库设计是整个项目的地基尤其是“推荐”功能它完全依赖表和表之间的关系。我设计了五张核心表用户表、美食分类表、美食信息表、美食评分表、收藏表。用户表sys_user的重点是角色和状态字段。角色用于区分普通用户和管理员状态用于管理员封禁用户CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码BCrypt加密, nickname VARCHAR(50) DEFAULT COMMENT 昵称, avatar VARCHAR(255) DEFAULT COMMENT 头像地址, role TINYINT DEFAULT 1 COMMENT 角色1-普通用户2-管理员, status TINYINT DEFAULT 1 COMMENT 状态1-正常0-禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;美食分类表food_category设计得非常轻量核心字段只有名称和排序权重CREATE TABLE food_category ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 分类ID, name VARCHAR(50) NOT NULL COMMENT 分类名川菜、粤菜、甜品、面点等, sort INT DEFAULT 0 COMMENT 排序权重数值越大越靠前 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT美食分类表;美食信息表food_info是业务核心除了基本信息外我特意把avg_score和likes做了冗余存储。这样首页热门推荐直接按平均分倒序就能查出结果不需要每次临时计算聚合值对个人项目的性能提升非常明显CREATE TABLE food_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 美食ID, title VARCHAR(100) NOT NULL COMMENT 美食名称, description TEXT COMMENT 美食描述, image_url VARCHAR(255) DEFAULT COMMENT 封面图地址, category_id BIGINT NOT NULL COMMENT 所属分类ID, price DECIMAL(10,2) DEFAULT 0 COMMENT 参考价格, avg_score DECIMAL(3,2) DEFAULT 0 COMMENT 平均评分冗余字段, likes INT DEFAULT 0 COMMENT 收藏热度冗余字段, status TINYINT DEFAULT 1 COMMENT 状态1-上架0-下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, INDEX idx_category (category_id), INDEX idx_score (avg_score DESC) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT美食信息表;美食评分表food_score单独建表而不是把评分冗余在美食表里是因为一道菜会被多个用户打分一人一评必须用唯一索引约束否则用户就能反复刷分了CREATE TABLE food_score ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 评分ID, user_id BIGINT NOT NULL COMMENT 用户ID, food_id BIGINT NOT NULL COMMENT 美食ID, score TINYINT NOT NULL COMMENT 评分1-5分, comment VARCHAR(500) DEFAULT COMMENT 评论内容, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_food (user_id, food_id), INDEX idx_food (food_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT美食评分表;收藏表user_favorite和评分表结构类似也要给“用户-美食”加唯一索引防止重复收藏CREATE TABLE user_favorite ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, food_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_food (user_id, food_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收藏表;这里有一个设计细节值得注意food_info的avg_score和likes是冗余字段也就是说它们是由评分表、收藏表聚合出来的。我选择在Service层更新评分或收藏时同步维护这两个字段而不是用数据库触发器。个人项目的读写量不大这种方式写起来更直观排查问题也更方便。2.2 MyBatis用XML写动态SQL推荐场景下最顺手的方式这个项目的查询条件非常灵活美食列表可能是按分类筛选也可能是按关键字搜索还可能是按最低评分过滤。如果用注解写SQL条件一多就很难维护。我的做法是Mapper接口定义方法SQL写在XML文件里。比如分页查询美食列表接受多个可选参数ListFoodVO selectFoodPage(Param(keyword) String keyword, Param(categoryId) Long categoryId, Param(minScore) Double minScore, Param(offset) int offset, Param(size) int size);对应的XMLselect idselectFoodPage resultTypecom.example.entity.vo.FoodVO SELECT f.id, f.title, f.description, f.image_url, f.price, f.avg_score, f.likes, c.name AS categoryName FROM food_info f LEFT JOIN food_category c ON f.category_id c.id where if testkeyword ! null and keyword ! AND f.title LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND f.category_id #{categoryId} /if if testminScore ! null AND f.avg_score gt; #{minScore} /if AND f.status 1 /where ORDER BY f.avg_score DESC LIMIT #{offset}, #{size} /select为什么这里不用MyBatis-Plus不是说Plus不好而是“推荐”相关的SQL比如按分类聚合、找相似用户的交集用XML能一眼看懂完整SQL结构更利于调试和扩展。Plus适合简单CRUD复杂查询时XML动态SQL的可读性明显更强。2.3 新手最容易漏掉的三个配置项这一节我单独拿出来说是因为这三个配置浪费过我不少时间第一下划线转驼峰映射。数据库字段是create_timeJava属性是createTime如果不在application.yml里配置下面这条查询结果里这两个字段永远为nullmybatis: configuration: map-underscore-to-camel-case: true第二插入数据后的主键回填。新增美食信息后前端马上要拿返回的id做后续操作。必须在Mapper上配置insert idinsertFood useGeneratedKeystrue keyPropertyid INSERT INTO food_info (title, description, image_url, category_id, price) VALUES (#{title}, #{description}, #{imageUrl}, #{categoryId}, #{price}) /insert第三逻辑删除。我没有直接DELETE数据而是用status字段标记。下架美食的执行语句是UPDATE food_info SET status 0 WHERE id #{id}这样即使误操作也能快速恢复所有查询SQL都统一带status 1条件。3. 后端推荐逻辑与接口实现SQL层面也能做出像样的推荐3.1 推荐策略拆解热门、偏好、相似用户这个项目的推荐引擎被我拆成了三个策略按不同场景调用第一热门推荐。适合做首页首屏的“热门榜单”核心就是按平均评分和收藏数倒序取前N条SELECT f.id, f.title, f.image_url, f.price, f.avg_score, f.likes, c.name AS categoryName FROM food_info f LEFT JOIN food_category c ON f.category_id c.id WHERE f.status 1 ORDER BY f.avg_score DESC, f.likes DESC LIMIT #{limit}第二基于分类偏好的推荐。思路是先查用户评分最高的前3个分类再从这些分类里找高评分且用户没吃过的美食。这也是我最推荐新手实现的推荐逻辑因为SQL直白、效果明显。找出用户偏好分类的SQLSELECT fc.id, ROUND(AVG(fs.score), 2) AS avg_score FROM food_score fs JOIN food_info fi ON fs.food_id fi.id JOIN food_category fc ON fi.category_id fc.id WHERE fs.user_id #{userId} GROUP BY fc.id ORDER BY avg_score DESC LIMIT 3拿到分类ID集合后再查推荐候选SELECT fi.id, fi.title, fi.image_url, fi.price, fi.avg_score, fi.likes FROM food_info fi WHERE fi.category_id IN foreach collectioncategoryIds itemcid open( separator, close) #{cid} /foreach AND fi.status 1 AND fi.id NOT IN (SELECT food_id FROM food_score WHERE user_id #{userId}) ORDER BY fi.avg_score DESC LIMIT #{limit}第三简化版协同过滤。真正的协同过滤算法要计算用户之间的皮尔逊相关系数个人项目没必要做到那一步。我的做法是用SQL先找出“跟你口味最接近的5个用户”判断标准就是你们给同一道菜打过分的交集数量SELECT fs2.user_id, COUNT(*) AS overlap_count FROM food_score fs1 JOIN food_score fs2 ON fs1.food_id fs2.food_id WHERE fs1.user_id #{userId} AND fs2.user_id ! #{userId} GROUP BY fs2.user_id ORDER BY overlap_count DESC LIMIT 5拿到这些相似用户ID后再取他们打过高分、且当前用户没评过的美食。这套简化方案的优点在于SQL逻辑容易理解面试时能讲清楚核心思想对个人项目的冷启动问题我直接兜底到热门推荐。3.2 Service层怎么组织把推荐算法和Controller隔离后端代码结构我按标准的三层来组织但重点在于Service层的推荐策略要独立出来controller ├── FoodController.java 美食查询、推荐接口 ├── UserController.java 登录注册、个人中心 └── AdminController.java 后台管理接口 service ├── RecommendService.java 推荐策略编排 ├── FoodService.java 美食业务逻辑 └── UserService.java 用户与鉴权逻辑 mapper ├── FoodMapper.java ├── FoodScoreMapper.java └── UserFavoriteMapper.java主要接口如下方法路径说明权限POST/api/user/login登录获取token公开POST/api/user/register注册公开GET/api/food/recommend获取推荐流登录GET/api/food/page分页查询美食公开GET/api/food/{id}美食详情公开POST/api/score/save提交评分评论登录POST/api/favorite/save收藏美食登录DELETE/api/favorite/{foodId}取消收藏登录POST/api/admin/food/save新增/修改美食管理员POST/api/admin/food/delete/{id}下架美食管理员Controller里不放业务逻辑只负责接收参数和返回统一格式。Service层里推荐接口的调用链路是这样的RecommendService先判断用户是否有评分记录有就执行偏好推荐协同过滤没有就返回热门推荐。这样设计的好处是推荐策略的调整只影响Service内部不影响接口定义。统一返回值我用了一个简单的Result类public class ResultT { private Integer code; private String msg; private T data; // 构造函数、getter/setter }成功返回code200未登录返回code401业务异常返回code500。前端只需要判断code就能统一处理错误这就是前后端分离协作的基础。3.3 JWT登录鉴权与管理员权限控制登录鉴权我选了JWT原因是它天然适合前后端分离。用户登录成功后后端签发一个token前端存到localStorage里每次请求在Header带上。核心流程登录接口验证用户名密码密码用BCrypt加密验证通过后生成tokenString token Jwts.builder() .setSubject(userId.toString()) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() 86400000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();拦截器里校验token并解析用户信息public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { return unAuthorized(response); } try { Claims claims Jwts.parser().setSigningKey(secretKey) .parseClaimsJws(authHeader.substring(7)).getBody(); request.setAttribute(userId, claims.getSubject()); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { return unAuthorized(response); } }管理员权限的控制我在拦截器中再判断一下路径前缀访问/api/admin/**时要求角色必须是管理员否则返回403。这个方案不复杂却能把用户端和管理端的权限边界理清楚。4. Vue前端页面与交互开发配合后端把体验做起来4.1 Vite初始化Vue3项目与路由设计前端我用的Vue3加Vite初始化命令很简单npm create vitelatest food-recommend-frontend -- --template vue进入项目后安装Vue Router和Axios路由表这样设计import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: home, component: () import(../views/HomeView.vue), meta: { title: 首页 } }, { path: /login, name: login, component: () import(../views/LoginView.vue) }, { path: /register, name: register, component: () import(../views/RegisterView.vue) }, { path: /food/:id, name: foodDetail, component: () import(../views/FoodDetailView.vue), meta: { title: 美食详情 } }, { path: /user, name: userCenter, component: () import(../views/UserCenterView.vue), meta: { requiresAuth: true } }, { path: /admin, name: admin, component: () import(../views/AdminView.vue), meta: { requiresAuth: true, requiresAdmin: true } } ]所有页面组件全部用路由懒加载这样打包时每个页面一个独立的chunk首次加载只下载首页部分体验会好很多。路由守卫里做两件事检查需要登录的页面是否带token检查管理页面当前用户的角色是否为管理员。为什么选createWebHistory而不是createWebHashHistoryhistory模式的URL更干净像http://localhost:8080/food/1没有心烦的#。但它有一个代价部署到服务器后直接刷新非首页路径会404这个问题我会在第五章给出解决办法。4.2 Axios封装baseURL、拦截器、Token注入Axios如果不做封装每个组件里都要重复写baseURL、处理错误码、加token代码会非常散乱。我的request.js封装如下import axios from axios import router from ../router const service axios.create({ baseURL: /api, timeout: 10000 }) 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 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(登录已过期)) } if (res.code ! 200) { return Promise.reject(new Error(res.msg || 请求失败)) } return res.data }, error { return Promise.reject(error) } )这样一个封装带来的直接好处是所有组件里的接口调用代码都变得非常干净比如推荐流接口只需要const list await getRecommend()。后端返回401时自动踢回登录页不用每个页面单独处理。前端的基础设施打好之后页面开发才能聚焦在UI和交互上。4.3 首页推荐流、详情页与管理后台的实现要点首页是整个系统的门面我把它分成了三个区块顶部是搜索框和分类Tab中间是“热门推荐”横滑卡片展示高分菜品下面是“猜你喜欢”展示基于用户行为的个性化推荐。每个美食卡片用独立的FoodCard组件承载展示封面图、美食名、均价、评分星级和收藏数。卡片点击后跳转到详情页。详情页的重点是评分和收藏交互。评分我用了一个简单的星星组件用户点击第几颗星组件就记录几分然后调用/api/score/save提交。提交成功后要刷新页面上的平均分和评论列表这一步非常直观地体现了“用户行为驱动数据更新”的闭环。管理后台用Element Plus的表格组件通过el-table展示美食列表el-dialog弹出新增/编辑表单。表单里包含标题、分类下拉、价格、描述和封面上传。我没有单独写复杂的上传组件而是用一个input file选择图片后直接调后端的图片上传接口返回的URL填到表单的图片字段里。管理后台不需要做得花哨能用、清晰、不出错就是胜利。5. 联调、部署与踩坑记录把这些坎提前排掉5.1 跨域问题的两种处理前后端分离开发第一个遇到的基本都是跨域。前端跑在http://localhost:5173后端跑在http://localhost:8080浏览器会因为协议、域名、端口不一致直接拦截请求。我的处理方式是双重保险。开发环境用Vite的proxy代理配置在vite.config.jsserver: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端代码里的/api请求在开发阶段都会被转发到后端的8080端口浏览器看到的始终是同源请求不会触发跨域。但部署到服务器后不存在Vite代理了所以后端也要加上CORS配置兜底。注意生产环境里allowedOrigins要写实际的前端域名不要用*配合allowCredentials(true)这个组合浏览器会直接拒绝。5.2 打包部署一个jar跑完系统我的部署方案很简单把Vue打包后的dist目录复制到SpringBoot项目的src/main/resources/static下重新mvn package最后得到一个jar。启动命令java -jar food-recommend-0.0.1-SNAPSHOT.jar浏览器直接访问8080端口就能看到首页接口和页面都在同一个服务里不存在跨域问题也省去配置Nginx的麻烦。这个方案最适合个人项目的演示和交付。使用history模式路由之后刷新/food/1页面会404因为SpringBoot默认找不到对应的Controller路由。我的解决方法是加一个转发Controller把前端路由直接转发到index.htmlController public class ForwardController { RequestMapping(value {/, /food/**, /user/**, /admin/**}) public String forward() { return forward:/index.html; } }如果你的部署环境里有Nginx也可以在Nginx配置里用try_files $uri $uri/ /index.html;解决方案B留给需要把前后端静态流量分离的场景。5.3 我实际踩过的几个典型问题开发过程中踩坑是难免的这里改成列表逐一说明希望能帮你避开同样的坑MySQL连接报Public Key Retrieval is not allowed。MySQL8默认使用caching_sha2_password认证JDBC连接时需要加参数allowPublicKeyRetrievaltrueuseSSLfalse。不解决的话第一次连接会直接被拒绝。返回时间差8小时。MySQL连接串里的serverTimezoneAsia/Shanghai必加不加的话时间字段会用默认时区解析导致查询结果和数据库里的实际时间差了8个小时排查起来很隐蔽。PageHelper分页总数不对。分页插件要求startPage必须紧跟要分页的查询语句中间不能穿插其他SQL。比如你先查了分类列表再去执行分页查询PageHelper会把分类查询也分页导致总数错乱。解决方法是把startPage和分页查询写在紧挨着的两行。图片上传成功后刷新丢失。本地保存的图片路径是物理路径用浏览器直接访问不到。需要在SpringBoot里配置资源映射把/uploads/**映射到本地目录Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourceLocations(file: System.getProperty(user.dir) /uploads/); } }MyBatis查询结果部分字段为null。原因就是2.3节说的下划线转驼峰没配置或者resultType写的是普通类而实际查询结果需要关联查询字段。建议所有联查结果统一封装VO字段名对得上才不容易出问题。5.4 两个值得做的扩展方向如果做完基础功能还想加点亮点我建议优先考虑这两个方向第一把图片上传从本地存储迁移到MinIO对象存储。本地存储最大的问题是服务器重启或换机器后文件丢失而且项目空间越来越大。MinIO的Java SDK接入不复杂在SpringBoot里写一个MinioService把上传逻辑统一封装调用方只需要传文件流和文件名就能拿到访问URL。第二给热门推荐接口加Redis缓存。推荐流的SQL虽然不复杂但首页是访问量最大的接口每次都查库没必要。用Redis缓存热门榜单设置5分钟的过期时间失效后自动重新查库。这个优化逻辑简单但对系统性能提升是立竿见影的也是面试时很自然的加分项。最后再分享一点个人体会这个项目做完我最满意的不是功能有多全而是“推荐”没有被做成摆设。从数据库里的评分表到Service层的推荐策略编排再到前端推荐流的展示整条链路是通的。很多人在做类似系统时推荐功能只是随便放几个随机数据凑数那样就失去了这个题目的价值。如果你正在做美食推荐系统或者准备用SpringBootVue做其他推荐类项目照着这个思路把数据行为先建起来推荐逻辑再简单也会显得扎实。遇到具体问题欢迎在评论区交流我看到了都会回复。