简介:这是一套面向计算机相关专业学生与Java实战学习者的美食推荐系统毕业设计完整资料,采用SpringBoot后端与Vue前端的主流技术栈,适合作为毕业设计、课程大作业或项目练手素材,难度适中,评审分达95分以上,源码经本地编译可运行。压缩包共635个文件,约22.23MB,其中160个Java文件承载后端业务逻辑,106个Vue文件构建前端页面,另有161个svg与72张jpg、31张png等图片资源用于界面展示,配套2个sql脚本、21个xml配置及若干js、css、scss样式文件,并附论文文档与pptx答辩材料,结构完整。目前已有247人学习下载。读者可获得一套可直接运行的全栈项目源码、数据库脚本与论文参考,便于快速理解推荐系统模块划分、前后端交互流程与部署方式,也能借助现成目录结构梳理开发思路,减少从零搭建的时间成本。
1. 从零到一:为什么“美食推荐系统”是毕业设计里最稳的选题之一
如果你正在为毕业设计选题发愁,或者已经选了“基于SpringBoot+Vue的美食推荐系统”却不知道从哪下手,这篇文章就是写给你的。我做过也带过不少届计算机毕业设计,说实话,美食推荐系统这个题目属于那种“看起来普通、做起来有料、答辩时不容易被问倒”的类型。它不像某些大数据选题那样环境难搭、数据难找,也不像纯算法选题那样容易陷入调参玄学。SpringBoot做后端、Vue做前端、MySQL存数据、协同过滤做推荐,这套组合拳打下来,技术栈完整、业务逻辑清晰、论文有东西可写。更重要的是,源码和论文之间能形成强对应关系——你代码里怎么实现的,论文里就怎么写,不用编。这篇文章我会把整个系统的搭建路径拆开讲,从环境配置到推荐算法落地,再到论文写作的对应关系,让你少走弯路。
2. 技术选型与环境搭建:为什么是SpringBoot+Vue而不是别的
2.1 后端选SpringBoot的四个现实理由
毕业设计选技术栈,第一原则不是“先进”,而是“能跑通、好调试、资料多”。SpringBoot在这三点上几乎是满分。我见过太多同学选了冷门框架,最后卡在依赖冲突上耗掉两周。SpringBoot的起步依赖机制把Web、MyBatis、Redis这些常用组件的版本兼容问题都处理好了,你只需要在pom.xml里声明spring-boot-starter-web和mybatis-spring-boot-starter,基本不会出现“jar包打架”的情况。
第二个理由是内置Tomcat。传统SSM项目要配web.xml、要部署到外部Tomcat,光是环境就够折腾半天。SpringBoot直接打成一个可执行jar,java -jar就能跑,本地开发和服务器部署用的是同一套东西。第三个理由是注解驱动开发,Controller层用@RestController、@RequestMapping,Service层用@Service、@Transactional,代码量比XML配置时代少了一半以上。第四个理由是生态完整,你需要分页有PageHelper,需要缓存有RedisTemplate,需要定时任务有@Scheduled,这些在美食推荐系统里都会用到。
提示:JDK版本建议用8或11,不要盲目上17。很多毕业设计用的MyBatis Generator和PageHelper在老版本JDK上更稳定,答辩前一周因为JDK版本问题跑不起来,那是真的没有后悔药。
2.2 前端选Vue的考虑与初始化步骤
Vue的优势在于上手曲线平缓,而且和SpringBoot配合是前后端分离的标准范式。你不需要学Webpack配置,用Vue CLI或者Vite就能把项目骨架搭起来。对于美食推荐系统来说,前端主要做三件事:展示菜品列表和详情、收集用户评分和收藏行为、渲染推荐结果。这些用Vue的组件化开发非常顺手。
初始化一个Vue项目的命令如下:
# 使用Vue CLI创建项目,选择默认配置即可 vue create food-recommend-frontend # 进入项目目录 cd food-recommend-frontend # 安装axios用于和后端通信,安装vue-router做路由 npm install axios vue-router --save # 安装element-ui或element-plus做UI组件库 npm install element-ui --save # 启动开发服务器 npm run serve这段命令的逻辑很直接:vue create生成项目骨架,npm install装三个核心依赖。axios负责发HTTP请求,vue-router管理页面跳转,element-ui提供表格、卡片、评分组件。参数上注意,如果你的Node版本是16以上,Vue CLI 4.x可能会有兼容性提示,但不影响运行。启动后默认端口是8080,如果被占用会在命令行提示你换端口。
2.3 数据库表设计的核心五张表
美食推荐系统的数据库不需要太复杂,但五张核心表必须设计好。用户表存账号密码和基本信息,菜品表存菜名、图片、描述、分类,评分表记录用户对菜品的打分,收藏表记录用户的收藏行为,推荐结果表可以缓存离线计算出的推荐列表。表结构设计时注意外键关系要清晰,评分表和收藏表都要有用户ID和菜品ID的联合索引,否则数据量上来后查询会明显变慢。
-- 用户表 CREATE TABLE `user` ( `user_id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL, `email` varchar(100) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 菜品表 CREATE TABLE `dish` ( `dish_id` int NOT NULL AUTO_INCREMENT, `dish_name` varchar(100) NOT NULL, `category` varchar(50) DEFAULT NULL, `description` text, `image_url` varchar(255) DEFAULT NULL, `avg_score` decimal(3,2) DEFAULT 0.00, PRIMARY KEY (`dish_id`), KEY `idx_category` (`category`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 评分表 CREATE TABLE `rating` ( `rating_id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `dish_id` int NOT NULL, `score` decimal(2,1) NOT NULL, `rating_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`rating_id`), UNIQUE KEY `uk_user_dish` (`user_id`,`dish_id`), KEY `idx_dish` (`dish_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;建表时有两个细节容易被忽略。一是字符集用utf8mb4而不是utf8,否则菜品描述里的特殊符号会乱码。二是评分表的uk_user_dish唯一索引,保证一个用户对同一道菜只能打一次分,后续如果要做修改就用ON DUPLICATE KEY UPDATE。菜品表的avg_score字段是冗余设计,每次有新评分时更新,这样列表页查询不用每次都做聚合计算。
3. 推荐算法落地:协同过滤在美食场景怎么调才准
3.1 用户基于协同过滤的实现步骤
协同过滤分两种:UserCF和ItemCF。美食推荐系统里我一般用UserCF,因为用户口味相似性比菜品相似性更容易解释。核心逻辑是:找到和目标用户评分习惯相似的一批用户,把他们高分且目标用户没吃过的菜推荐过来。实现分四步:构建用户-菜品评分矩阵、计算用户相似度、找最近邻、生成推荐列表。
import numpy as np from sklearn.metrics.pairwise import cosine_similarity # 假设有5个用户对6道菜的评分矩阵,0表示未评分 ratings = np.array([ [5, 3, 0, 1, 0, 4], [4, 0, 0, 1, 2, 5], [1, 1, 0, 5, 0, 0], [0, 0, 5, 4, 0, 0], [0, 1, 5, 4, 0, 0], ]) # 计算用户之间的余弦相似度 user_sim = cosine_similarity(ratings) print("用户相似度矩阵:") print(np.round(user_sim, 2)) # 为目标用户(索引0)找最相似的2个用户 target_user = 0 sim_scores = list(enumerate(user_sim[target_user])) sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True) neighbors = sim_scores[1:3] # 排除自己 print(f"目标用户{target_user}的最近邻:{neighbors}") # 基于邻居评分预测目标用户对未评分菜品的分数 def predict_rating(user_id, dish_id, neighbors, ratings): numerator = 0 denominator = 0 for neighbor_id, similarity in neighbors: if ratings[neighbor_id][dish_id] > 0: numerator += similarity * ratings[neighbor_id][dish_id] denominator += similarity if denominator == 0: return 0 return numerator / denominator # 预测目标用户对第3道菜(索引2)的评分 pred = predict_rating(0, 2, neighbors, ratings) print(f"预测目标用户对菜品3的评分:{pred:.2f}")这段代码的关键在cosine_similarity,它把每个用户的评分向量做余弦计算,得到相似度矩阵。neighbors取相似度最高的前两个用户,排除自己。predict_rating函数用加权平均的方式预测评分,权重就是相似度。参数上注意,评分矩阵里的0表示未评分,计算时要排除,否则会把“没吃过”当成“不喜欢”。实际系统中评分数据从MySQL的rating表读取,用Pandas做透视表就能得到矩阵。
3.2 冷启动问题的三种工程解法
协同过滤最大的问题是冷启动:新用户没有评分数据,新菜品没有被人评过。毕业设计里如果只讲算法不讲冷启动,答辩老师大概率会追问。我一般用三种方式组合解决。第一种是基于内容的推荐兜底,新用户注册时让他选几个喜欢的口味标签,系统直接推荐对应分类的高分菜品。第二种是热门推荐,把全站评分最高且评分人数最多的菜品排前面,保证新用户一进来就有东西看。第三种是随机探索,在推荐列表里混入少量用户没接触过的品类,既能收集反馈数据,又能避免推荐结果过于单一。
// SpringBoot中实现热门推荐的Service方法 @Service public class RecommendService { @Autowired private DishMapper dishMapper; // 热门推荐:按平均分和评分人数加权排序 public List<Dish> getHotDishes(int limit) { return dishMapper.selectHotDishes(limit); } // 基于内容的兜底推荐:根据用户选择的标签查菜品 public List<Dish> getDishesByTags(List<String> tags, int limit) { return dishMapper.selectByCategories(tags, limit); } }对应的MyBatis SQL这样写:
<select id="selectHotDishes" resultType="com.example.entity.Dish"> SELECT d.*, COUNT(r.rating_id) as rating_count FROM dish d LEFT JOIN rating r ON d.dish_id = r.dish_id GROUP BY d.dish_id HAVING rating_count >= 3 ORDER BY d.avg_score DESC, rating_count DESC LIMIT #{limit} </select>这个查询的逻辑是:先关联评分表统计每道菜的评分人数,过滤掉评分人数少于3的(避免一个5分菜排第一),然后按平均分降序、评分人数降序排列。参数limit控制返回条数,一般首页展示8到12条。注意HAVING子句里用了别名rating_count,MySQL是支持的,但某些数据库不兼容,如果移植要注意。
3.3 推荐结果怎么存怎么取
实时计算推荐结果对毕业设计来说没必要,数据量不大但每次请求都算一遍相似度矩阵太浪费。我一般用离线计算+缓存的方式:写一个定时任务,每天凌晨跑一次推荐算法,把每个用户的TopN推荐结果写入推荐结果表。前端请求推荐接口时直接查表返回,响应时间从秒级降到毫秒级。
@Component public class RecommendTask { @Autowired private RecommendMapper recommendMapper; @Autowired private RatingMapper ratingMapper; // 每天凌晨2点执行离线推荐计算 @Scheduled(cron = "0 0 2 * * ?") public void generateRecommendations() { List<Integer> userIds = recommendMapper.selectAllUserIds(); for (Integer userId : userIds) { List<Integer> recommendedDishIds = calculateCFRecommend(userId); recommendMapper.deleteByUserId(userId); for (int i = 0; i < recommendedDishIds.size(); i++) { recommendMapper.insert(userId, recommendedDishIds.get(i), i + 1); } } } private List<Integer> calculateCFRecommend(Integer userId) { // 这里调用协同过滤计算逻辑,返回推荐菜品ID列表 // 实际实现可以用Java直接算,也可以调用Python脚本 return new ArrayList<>(); } }@Scheduled注解的cron表达式0 0 2 * * ?表示每天凌晨2点执行。deleteByUserId先清空旧推荐再插入新推荐,避免数据累积。推荐结果表里存一个rank字段表示推荐位次,前端按这个排序展示。如果答辩时老师问“推荐结果多久更新一次”,你就说每天离线更新一次,实时行为数据用于第二天计算,这是工业界常见的做法。
4. 前后端联调与接口设计:把数据从数据库搬到页面上
4.1 统一响应格式与跨域配置
前后端分离项目第一个坑就是跨域。Vue开发服务器跑在8080端口,SpringBoot跑在8081端口,浏览器直接请求会被CORS策略拦截。解决办法是在SpringBoot里加一个全局跨域配置类,或者在Controller上加@CrossOrigin注解。我推荐全局配置,一次搞定所有接口。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }allowedOriginPatterns("*")在SpringBoot 2.4以上版本替代了allowedOrigins("*"),因为后者和allowCredentials(true)不能同时使用。maxAge(3600)表示预检请求的结果缓存一小时,减少OPTIONS请求次数。统一响应格式用一个Result<T>类包装,包含code、msg、data三个字段,前端根据code判断请求是否成功。
@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMsg("success"); result.setData(data); return result; } public static <T> Result<T> error(String msg) { Result<T> result = new Result<>(); result.setCode(500); result.setMsg(msg); return result; } }4.2 菜品列表与推荐接口的Controller写法
菜品列表接口需要支持分页和分类筛选,推荐接口直接返回离线计算的结果。两个接口的Controller写法如下:
@RestController @RequestMapping("/api/dish") public class DishController { @Autowired private DishService dishService; // 分页查询菜品列表,支持按分类筛选 @GetMapping("/list") public Result<PageInfo<Dish>> list( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String category) { PageHelper.startPage(pageNum, pageSize); List<Dish> dishes = dishService.getDishesByCategory(category); PageInfo<Dish> pageInfo = new PageInfo<>(dishes); return Result.success(pageInfo); } // 获取当前用户的推荐菜品列表 @GetMapping("/recommend") public Result<List<Dish>> recommend(@RequestParam Integer userId) { List<Dish> dishes = dishService.getRecommendDishes(userId); return Result.success(dishes); } }PageHelper.startPage是MyBatis分页插件的核心方法,它会在下一次查询时自动加上LIMIT语句。PageInfo封装了总页数、总记录数、当前页数据等信息,前端直接拿这个对象渲染分页组件。推荐接口的userId参数从登录后的Session或Token里取,这里为了演示简化成请求参数。
4.3 Vue前端调用接口与页面渲染
前端用axios调用后端接口,在src/api目录下统一管理请求方法:
// src/api/dish.js import request from '@/utils/request' export function getDishList(params) { return request({ url: '/api/dish/list', method: 'get', params: params }) } export function getRecommendList(userId) { return request({ url: '/api/dish/recommend', method: 'get', params: { userId } }) }request是封装了axios的实例,统一设置了baseURL和拦截器。在菜品列表页面,用v-for渲染菜品卡片,用el-pagination做分页:
<template> <div class="dish-list"> <el-row :gutter="20"> <el-col :span="6" v-for="dish in dishList" :key="dish.dishId"> <el-card :body-style="{ padding: '0px' }"> <img :src="dish.imageUrl" class="dish-image"> <div class="dish-info"> <h3>{{ dish.dishName }}</h3> <el-rate v-model="dish.avgScore" disabled show-score></el-rate> <p>{{ dish.description }}</p> </div> </el-card> </el-col> </el-row> <el-pagination @current-change="handlePageChange" :current-page="pageNum" :page-size="pageSize" :total="total" layout="prev, pager, next"> </el-pagination> </div> </template> <script> import { getDishList } from '@/api/dish' export default { data() { return { dishList: [], pageNum: 1, pageSize: 10, total: 0 } }, created() { this.fetchData() }, methods: { fetchData() { getDishList({ pageNum: this.pageNum, pageSize: this.pageSize }).then(res => { this.dishList = res.data.list this.total = res.data.total }) }, handlePageChange(page) { this.pageNum = page this.fetchData() } } } </script>这段Vue代码的逻辑是:created钩子触发首次数据加载,fetchData调用API拿到分页数据,dishList和total分别绑定到模板。el-rate组件用disabled模式展示平均分,show-score显示具体数值。分页组件的current-change事件触发翻页,重新请求数据。
5. 避坑与排查:那些年我踩过的血泪坑
5.1 跨域配置不生效的三种情况
现象:前端请求一直报CORS错误,明明加了@CrossOrigin或者全局配置类,浏览器控制台还是显示“No 'Access-Control-Allow-Origin' header”。原因通常有三种:一是配置类没有被扫描到,检查启动类是否在配置类所在包的父级;二是同时用了allowedOrigins("*")和allowCredentials(true),SpringBoot 2.4以上会直接报错,必须换成allowedOriginPatterns("*");三是请求被Spring Security拦截了,如果项目里引入了Security依赖,需要在Security配置里额外开启CORS。解决方法是先看启动日志有没有报错,再用Postman直接请求后端接口,如果Postman能通而浏览器不通,那一定是CORS问题。
5.2 分页查询返回总数不对
现象:PageHelper分页后,PageInfo.getTotal()返回的是当前页的记录数而不是总记录数。原因通常是PageHelper.startPage之后紧跟的查询不是预期的那条,中间插入了其他查询语句。PageHelper的原理是拦截下一次MyBatis查询,如果startPage和实际查询之间隔了别的数据库操作,分页就会作用到错误的SQL上。解决办法是确保startPage紧挨着目标查询,中间不要插入其他Mapper调用。
5.3 推荐结果为空或重复
现象:推荐接口返回空列表,或者同一个菜品重复出现。原因有两个:一是离线计算任务没有执行,推荐结果表是空的;二是推荐结果表没有做去重,同一个菜品被插入了多次。排查时先查推荐结果表有没有数据,再查SQL里有没有DISTINCT或GROUP BY。解决方法是定时任务加日志输出,每次执行后打印处理了多少用户、插入了多少条记录。插入前先deleteByUserId清空旧数据,插入时用INSERT IGNORE或唯一索引防止重复。
5.4 图片上传后访问404
现象:菜品图片上传成功,数据库里也有路径,但前端img标签加载不出来。原因是SpringBoot默认不会把上传目录映射为静态资源路径。解决办法是加一个配置类,把上传目录映射到URL路径上:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + System.getProperty("user.dir") + "/upload/"); } }addResourceHandler定义URL前缀,addResourceLocations定义实际文件路径。System.getProperty("user.dir")获取项目运行目录,保证本地和服务器路径一致。上传时把文件保存到upload目录,数据库存/upload/xxx.jpg,前端就能正常访问了。
5.5 论文查重率过高怎么降
现象:论文初稿查重率30%以上,尤其是技术选型和系统设计章节。原因是这些章节容易直接抄网上的模板。解决办法是:技术选型部分用自己的话描述为什么选这个技术,结合你实际开发中遇到的问题;系统设计部分把ER图、流程图用自己的工具重新画一遍,图注和说明文字自己写;推荐算法部分把公式和代码对应起来讲,公式用MathType打,代码贴关键片段并逐行解释。我一般建议论文里代码不要超过总篇幅的15%,重点放在设计思路和测试结果上。
6. 从源码到论文:怎么把代码逻辑翻译成学术表达
6.1 论文各章节与代码模块的对应关系
毕业设计论文和源码不是两张皮,而是同一件事的两种表达。第一章绪论写研究背景和意义,对应你为什么要做美食推荐系统;第二章相关技术写SpringBoot和Vue的特点,对应你技术选型的理由;第三章需求分析写功能需求和非功能需求,对应你系统里有哪些模块;第四章系统设计写架构图和数据库设计,对应你的包结构和表结构;第五章系统实现写核心代码和界面截图,对应你的Controller和Vue组件;第六章测试写测试用例和结果,对应你跑通的接口和页面。把代码里的类名、方法名、表名整理成表格放进论文,查重率自然就降下来了。
6.2 推荐算法章节的写作模板
推荐算法是论文里最容易写空的部分。我一般用“问题描述→算法选择→公式推导→实现步骤→结果分析”这个结构。问题描述写冷启动和稀疏性问题,算法选择写为什么用UserCF而不是ItemCF,公式推导写余弦相似度和预测评分的数学表达,实现步骤写代码里的关键函数,结果分析写推荐准确率或用户满意度。公式用MathType打,不要截图,截图会被查重系统识别为图片但导师会要求你改。
// 论文里可以引用这段代码说明相似度计算 public double calculateSimilarity(Map<Integer, Double> user1, Map<Integer, Double> user2) { double dotProduct = 0.0; double norm1 = 0.0; double norm2 = 0.0; for (Integer dishId : user1.keySet()) { if (user2.containsKey(dishId)) { dotProduct += user1.get(dishId) * user2.get(dishId); } norm1 += Math.pow(user1.get(dishId), 2); } for (Double score : user2.values()) { norm2 += Math.pow(score, 2); } if (norm1 == 0 || norm2 == 0) { return 0.0; } return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); }这段代码用Java实现了余弦相似度,和Python版本的逻辑完全一致。论文里可以写“如代码所示,相似度计算通过遍历两个用户的共同评分菜品,计算点积和模长,最终得到余弦值”。注意norm1在第一个循环里累加,norm2在第二个循环里累加,这样写比两个循环都遍历user1和user2的并集更高效。
6.3 答辩前必须跑通的三条命令
答辩前一周,把这三条命令跑通,基本不会出大问题。第一条是后端打包命令mvn clean package -DskipTests,确保能打出可执行jar。第二条是前端打包命令npm run build,确保能生成dist目录。第三条是数据库初始化命令,把建表SQL和数据SQL按顺序执行一遍,确保从零开始能跑起来。我一般还会准备一个start.sh脚本,把启动命令写进去,答辩演示时一键启动,避免手忙脚乱。
#!/bin/bash # 启动后端 nohup java -jar food-recommend-backend-1.0.0.jar > backend.log 2>&1 & # 启动前端(如果用Nginx部署,这步可以省略) # nohup serve -s dist -l 8080 > frontend.log 2>&1 & echo "系统启动完成,后端端口8081,前端端口8080"这个脚本用nohup让进程在后台运行,日志输出到文件。答辩时如果老师问“你的系统怎么部署”,你就说打成jar包用java -jar运行,前端打包成静态文件用Nginx托管,数据库用MySQL 5.7或8.0。这是最标准、最不容易出错的部署方式。
说实话,毕业设计这件事,选题对了就成功了一半,剩下的一半靠的是把代码跑通、把逻辑讲清楚。美食推荐系统这个题目,技术栈成熟、业务逻辑直观、论文素材丰富,只要你按这篇文章的路径走一遍,从环境搭建到推荐算法再到论文写作,每一步都有对应的代码和解释。我见过太多同学卡在环境配置或者跨域问题上耗掉大量时间,其实这些问题都有标准解法。希望帮到你,少踩坑,顺利过答辩。
本文还有配套的精品资源,点击获取