☰
SpringBoot+Vue美食推荐系统毕设:协同过滤算法落地与避坑指南
2026/10/1 1:24:37 网站建设 项目流程

简介:这份资源是面向计算机相关专业学生与Java实战学习者的美食推荐系统毕业设计完整方案,采用SpringBoot后端与Vue前端的主流技术栈,适合作为毕业设计、课程大作业或项目练手素材,难度适中,评审分达到95分以上,源码均经本地编译验证可运行。压缩包共635个文件,约22.23MB,其中160个Java文件构成后端业务逻辑,106个Vue文件实现前端页面交互,另有161个svg图标、72张jpg与31张png图片资源、36个js脚本、21个xml配置及2个sql建表脚本,并附论文文档与项目说明,目录结构清晰,便于按模块查阅与二次开发。目前已有247人学习下载。读者可从中获得一套可直接运行的前后端分离项目源码、配套论文写作参考、数据库脚本与依赖配置,以及推荐算法与页面组件的实现思路,帮助快速理解系统架构、完成答辩准备与功能扩展。

1. 美食推荐系统毕设:从选题到跑通的完整路径

每年毕业季,计算机专业的学生都会面临同一个灵魂拷问:做什么选题能既有技术含量,又能顺利通过答辩?在众多候选方向里,基于 SpringBoot + Vue 的美食推荐系统一直是高频出现的选择。原因很直接——它同时覆盖了后端开发、前端交互、数据库设计和推荐算法四个维度,工作量饱满,技术栈主流,答辩时也容易讲清楚。但问题在于,网上能搜到的所谓“高分毕业设计”源码,质量参差不齐,很多项目跑不起来、代码结构混乱、推荐逻辑形同虚设。这篇内容不讲空话,只讲一件事:如果你决定做这个方向,从环境搭建到推荐算法落地,每一步该怎么做、参数怎么设、哪里容易翻车。

读者对象很明确:正在准备毕业设计、需要一套能跑通且能讲清楚技术方案的同学。无论你是刚学完 SpringBoot 基础、对 Vue 只有入门了解,还是已经写过一些 CRUD 想加点真正的推荐逻辑,下面的内容都能直接对照操作。整个方案的核心思路是:用 SpringBoot 做后端服务,Vue 做前后端分离的交互层,MySQL 存数据,推荐算法先用协同过滤跑通最小闭环,再考虑优化。不追求花哨,追求的是每一步都能复现、每一个参数都有理由。

2. 技术选型与环境搭建:为什么是 SpringBoot + Vue 而不是别的

2.1 后端选 SpringBoot 的三个现实理由

做毕业设计选技术栈,第一原则不是“哪个最先进”,而是“哪个能在有限时间内跑通且不容易出玄学问题”。SpringBoot 在这个场景下的优势非常具体。

第一,自动配置省掉了大量 XML。传统 SSM 项目光是把 SpringMVC、MyBatis、数据源整合起来就要写一堆配置文件,版本不匹配就报错。SpringBoot 的 starter 机制把常用依赖打包好了,引入spring-boot-starter-web和spring-boot-starter-data-jpa或mybatis-spring-boot-starter,基本就能启动一个带数据库连接的 Web 服务。

第二,内嵌 Tomcat,打包成 jar 直接跑。答辩演示的时候不需要在评委电脑上装 Tomcat,java -jar一条命令启动,这在现场演示时能救命。

第三,生态成熟,遇到问题能搜到答案。毕业设计最怕遇到一个冷门框架的坑,搜半天搜不到解决方案。SpringBoot 的用户基数大,常见报错基本都有现成的排查记录。

版本选择上,我一般建议用 SpringBoot 2.7.x 配合 JDK 8 或 JDK 11。SpringBoot 3.x 要求 JDK 17,虽然新,但部分学校实验室环境还停留在 JDK 8,贸然用 3.x 可能在环境上卡住。MySQL 用 5.7 或 8.0 都可以,8.0 在排序规则和 JSON 支持上更好,但要注意驱动类名从com.mysql.jdbc.Driver变成了com.mysql.cj.jdbc.Driver。

2.2 前端选 Vue 的考量与版本决策

Vue 在这个项目里的角色是提供用户界面和交互。选 Vue 而不是 React 或 Angular,主要原因是学习曲线。Vue 的模板语法对新手友好,v-for、v-if、v-model这些指令看名字就知道干什么,配合 Element Plus 或 Ant Design Vue 组件库,很快能搭出一个像样的页面。

版本上,Vue 2 和 Vue 3 的选择需要想清楚。Vue 2 的生态更成熟,很多教程和第三方组件还是基于 Vue 2 写的,遇到问题更容易找到参考。Vue 3 的组合式 API 更灵活,但如果你对前端不是特别熟,调试响应式问题时可能会多花时间。我的建议是:如果学校没有强制要求 Vue 3,用 Vue 2 + Element UI 能更快出活;如果想让论文的技术部分看起来更新一些,用 Vue 3 + Element Plus,但要做好多花一两天踩坑的准备。

2.3 从零搭建项目骨架的完整命令

下面是从空目录开始搭建前后端项目的步骤。后端用 Maven 构建,前端用 Vue CLI。

# 后端:创建 SpringBoot 项目目录结构 mkdir food-recommend-backend cd food-recommend-backend # 用 Spring Initializr 的 curl 方式生成基础项目(也可以去 start.spring.io 网页生成) curl https://start.spring.io/starter.zip \ -d type=maven-project \ -d language=java \ -d bootVersion=2.7.18 \ -d groupId=com.example \ -d artifactId=food-recommend \ -d name=food-recommend \ -d packageName=com.example.foodrecommend \ -d dependencies=web,mysql,mybatis,lombok \ -o food-recommend.zip unzip food-recommend.zip

这段命令的作用是生成一个包含 Web、MySQL 驱动、MyBatis 和 Lombok 依赖的 SpringBoot 项目骨架。bootVersion指定 2.7.18 是为了兼容 JDK 8。dependencies里的lombok能减少实体类的 getter/setter 代码量,后面写实体类会轻松很多。

# 前端:用 Vue CLI 创建项目 npm install -g @vue/cli vue create food-recommend-frontend # 选择 Manually select features # 勾选 Router、Vuex、CSS Pre-processors # Vue 版本选 2.x # 路由模式选 history # CSS 预处理器选 Sass/SCSS

前端创建过程中会交互式询问配置,按上面注释选择即可。创建完成后进入目录安装依赖:

cd food-recommend-frontend npm install axios element-ui --save npm run serve

axios用于和后端 API 通信,element-ui提供表格、表单、卡片等组件。npm run serve启动开发服务器,默认在 8080 端口,如果和后端端口冲突,可以在vue.config.js里改devServer.port。

2.4 数据库表结构设计的最小可用集

美食推荐系统的数据模型不需要太复杂,但几个核心表必须设计合理。下面是最小可用集的建表 SQL。

-- 用户表 CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL, `avatar` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 美食/菜品表 CREATE TABLE `food` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL, `category` VARCHAR(50) DEFAULT NULL, `description` TEXT, `image_url` VARCHAR(255) DEFAULT NULL, `avg_score` DECIMAL(3,1) DEFAULT 0.0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 用户评分表(推荐算法的核心数据来源) CREATE TABLE `rating` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `food_id` INT NOT NULL, `score` TINYINT NOT NULL COMMENT '评分1-5', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_food` (`user_id`, `food_id`), FOREIGN KEY (`user_id`) REFERENCES `user`(`id`), FOREIGN KEY (`food_id`) REFERENCES `food`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

rating表上的唯一索引uk_user_food很关键,它保证一个用户对同一道菜只能有一条评分记录,避免协同过滤计算时出现重复数据。score用 TINYINT 存 1 到 5 的整数评分,比用浮点数更省空间,也符合大多数评分场景的习惯。

注意:建表时字符集统一用utf8mb4,不要用utf8。MySQL 的utf8实际上是阉割版,存不了 emoji 和部分生僻字,菜品名称里如果有特殊字符会插入失败。

3. 推荐算法落地:协同过滤从原理到代码

3.1 为什么选协同过滤而不是内容推荐

推荐算法有很多种,毕业设计里常见的有基于内容的推荐、协同过滤、混合推荐。美食推荐这个场景下,协同过滤是最合适的选择,原因有两个。

第一,美食的“内容特征”很难量化。一道菜可以打上“川菜”“辣”“荤菜”这些标签,但用户的口味偏好远不是几个标签能描述的。有人喜欢辣但不喜欢麻,有人喜欢甜但讨厌太甜,这些细微差异用内容特征表达很吃力。而协同过滤不需要知道菜品具体是什么味道,只需要知道“和你口味相似的人还喜欢什么”。

第二,协同过滤有用户行为数据就能跑。评分表里每多一条记录,推荐结果就更准一点。这个特性在答辩时很好讲——数据驱动,用户越多推荐越准。

协同过滤分两类:UserCF 和 ItemCF。UserCF 是找相似用户,推荐相似用户喜欢的菜;ItemCF 是找相似菜品,推荐和你历史喜欢的菜相似的菜。美食场景下,ItemCF 通常更稳定,因为菜品之间的相似度比用户之间的相似度更不容易随时间剧烈变化。但 UserCF 实现起来更直观,下面两种都会讲。

3.2 用皮尔逊相关系数计算用户相似度

UserCF 的核心是计算两个用户对同一组菜品的评分相似程度。皮尔逊相关系数是常用方法,它衡量的是两个变量之间的线性相关程度,取值范围 -1 到 1。

/** * 计算两个用户的皮尔逊相关系数 * @param userRatings1 用户1的评分映射:foodId -> score * @param userRatings2 用户2的评分映射:foodId -> score * @return 相关系数,-1到1之间;无共同评分项时返回0 */ public double pearsonCorrelation(Map<Integer, Integer> userRatings1, Map<Integer, Integer> userRatings2) { // 找出两个用户共同评分的菜品 Set<Integer> commonFoods = new HashSet<>(userRatings1.keySet()); commonFoods.retainAll(userRatings2.keySet()); int n = commonFoods.size(); if (n == 0) { return 0; // 没有共同评分,无法计算相似度 } double sum1 = 0, sum2 = 0; double sum1Sq = 0, sum2Sq = 0; double pSum = 0; for (Integer foodId : commonFoods) { int r1 = userRatings1.get(foodId); int r2 = userRatings2.get(foodId); sum1 += r1; sum2 += r2; sum1Sq += r1 * r1; sum2Sq += r2 * r2; pSum += r1 * r2; } // 皮尔逊相关系数公式 double numerator = pSum - (sum1 * sum2 / n); double denominator = Math.sqrt( (sum1Sq - sum1 * sum1 / n) * (sum2Sq - sum2 * sum2 / n) ); if (denominator == 0) { return 0; // 分母为0说明某个用户对所有菜品评分相同,无区分度 } return numerator / denominator; }

这段代码的逻辑是:先找两个用户共同评分的菜品集合,如果交集为空直接返回 0。然后累加各自的评分和、评分平方和、评分乘积和,最后套皮尔逊公式。denominator == 0的情况要单独处理,否则会抛除零异常。这种情况出现在某个用户给所有菜都打了同样的分,比如全是 3 分,此时该用户的评分方差为 0,相关系数没有意义。

参数说明:userRatings1和userRatings2是从rating表按用户 ID 查出来的评分数据,key 是food_id,value 是score。实际查询时用 MyBatis 的selectByUserId方法返回List<Rating>,再在 Java 里转成 Map。

3.3 基于相似用户生成推荐列表

有了相似度计算方法,下一步是给目标用户生成推荐。思路是:找到和目标用户最相似的 K 个用户,把他们评分高但目标用户没吃过的菜推荐出来。

/** * 基于 UserCF 为目标用户生成推荐 * @param targetUserId 目标用户ID * @param allRatings 所有用户的评分数据:userId -> (foodId -> score) * @param k 选取最相似的K个用户 * @param recommendN 推荐N道菜 * @return 推荐菜品ID列表,按预测评分从高到低排序 */ public List<Integer> recommendByUserCF(int targetUserId, Map<Integer, Map<Integer, Integer>> allRatings, int k, int recommendN) { Map<Integer, Integer> targetRatings = allRatings.get(targetUserId); if (targetRatings == null || targetRatings.isEmpty()) { return Collections.emptyList(); // 目标用户无评分记录,冷启动 } // 1. 计算目标用户与其他所有用户的相似度 List<UserSimilarity> similarities = new ArrayList<>(); for (Map.Entry<Integer, Map<Integer, Integer>> entry : allRatings.entrySet()) { int otherUserId = entry.getKey(); if (otherUserId == targetUserId) continue; double sim = pearsonCorrelation(targetRatings, entry.getValue()); if (sim > 0) { // 只保留正相关用户 similarities.add(new UserSimilarity(otherUserId, sim)); } } // 2. 按相似度降序排列,取前K个 similarities.sort((a, b) -> Double.compare(b.similarity, a.similarity)); List<UserSimilarity> topK = similarities.subList(0, Math.min(k, similarities.size())); // 3. 加权预测目标用户对未评分菜品的分数 Map<Integer, Double> predictedScores = new HashMap<>(); Map<Integer, Double> simSumMap = new HashMap<>(); for (UserSimilarity us : topK) { Map<Integer, Integer> neighborRatings = allRatings.get(us.userId); for (Map.Entry<Integer, Integer> rating : neighborRatings.entrySet()) { int foodId = rating.getKey(); if (targetRatings.containsKey(foodId)) continue; // 跳过已评分的 double weightedScore = us.similarity * rating.getValue(); predictedScores.merge(foodId, weightedScore, Double::sum); simSumMap.merge(foodId, us.similarity, Double::sum); } } // 4. 归一化并排序 List<Map.Entry<Integer, Double>> finalScores = new ArrayList<>(); for (Map.Entry<Integer, Double> entry : predictedScores.entrySet()) { int foodId = entry.getKey(); double score = entry.getValue() / simSumMap.get(foodId); finalScores.add(new AbstractMap.SimpleEntry<>(foodId, score)); } finalScores.sort((a, b) -> Double.compare(b.getValue(), a.getValue())); // 5. 取前N个 return finalScores.stream() .limit(recommendN) .map(Map.Entry::getKey) .collect(Collectors.toList()); }

这段代码分五步走。第一步遍历所有其他用户,用皮尔逊方法算相似度,只保留正相关的——负相关意味着口味相反,推荐结果会适得其反。第二步取相似度最高的 K 个用户,K 一般设 10 到 20,太小推荐结果不稳定,太大计算量增加且可能引入不相关用户。第三步做加权预测,权重就是相似度,相似度越高的用户评分对预测影响越大。第四步归一化,除以相似度之和,避免热门菜品因为被更多邻居评分而分数虚高。第五步取前 N 个返回。

参数设置上,k建议从 10 开始试,recommendN一般设 10 到 20。如果推荐结果里出现大量冷门菜品,说明 K 太小;如果推荐结果总是那几道热门菜,说明 K 太大或者归一化没做好。

3.4 接口层:把推荐结果暴露给前端

算法跑通后,需要用一个 REST 接口把结果返回给 Vue 前端。下面是对应的 Controller。

@RestController @RequestMapping("/api/recommend") public class RecommendController { @Autowired private RatingService ratingService; @Autowired private FoodService foodService; @GetMapping("/user/{userId}") public Result<List<FoodVO>> recommendForUser( @PathVariable int userId, @RequestParam(defaultValue = "10") int n) { // 1. 从数据库加载所有评分数据 Map<Integer, Map<Integer, Integer>> allRatings = ratingService.loadAllRatings(); // 2. 调用推荐算法 List<Integer> foodIds = recommendByUserCF(userId, allRatings, 15, n); // 3. 根据菜品ID查询详细信息 List<FoodVO> foods = foodService.findByIds(foodIds); return Result.success(foods); } }

loadAllRatings()方法从rating表一次性查出所有数据,在内存里构建Map<Integer, Map<Integer, Integer>>结构。数据量小的时候没问题,如果评分数据超过几万条,每次请求都全量加载会拖慢响应。优化方向是用缓存或者离线计算推荐结果存到 Redis,但毕业设计阶段数据量通常不大,直接查库够用。

Result是统一响应封装类,包含code、msg、data三个字段。前端 axios 拿到响应后判断code == 200再取data渲染。

提示:接口路径里的{userId}用@PathVariable接收,n用@RequestParam接收并设默认值。如果前端不传n,默认返回 10 条推荐。

4. 前后端联调与核心页面实现

4.1 Vue 前端调用推荐接口的完整流程

前端需要做三件事:在页面加载时请求推荐接口、把返回的菜品列表渲染成卡片、处理加载中和空数据状态。

// Recommend.vue 组件 export default { data() { return { recommendList: [], loading: false, userId: null }; }, created() { // 从 localStorage 获取当前登录用户ID this.userId = localStorage.getItem('userId'); if (this.userId) { this.fetchRecommend(); } }, methods: { async fetchRecommend() { this.loading = true; try { const res = await this.$axios.get( `/api/recommend/user/${this.userId}`, { params: { n: 12 } } ); if (res.data.code === 200) { this.recommendList = res.data.data; } else { this.$message.error(res.data.msg || '推荐加载失败'); } } catch (err) { console.error('推荐接口请求异常', err); this.$message.error('网络异常,请稍后重试'); } finally { this.loading = false; } } } };

created钩子里先取userId,没有登录就不请求。fetchRecommend用async/await处理异步请求,try/catch/finally保证无论成功失败loading都会复位。params里的n: 12表示请求 12 条推荐,和页面卡片布局的列数匹配。

模板部分用v-for渲染卡片,v-if控制加载状态和空状态:

<template> <div class="recommend-page"> <div v-if="loading" class="loading-box"> <i class="el-icon-loading"></i> 正在为你推荐... </div> <div v-else-if="recommendList.length === 0" class="empty-box"> 暂无推荐,先去评分几道菜吧 </div> <div v-else class="food-grid"> <el-card v-for="food in recommendList" :key="food.id" class="food-card"> <img :src="food.imageUrl" class="food-img" /> <div class="food-name">{{ food.name }}</div> <div class="food-score">评分:{{ food.avgScore }}</div> </el-card> </div> </div> </template>

空状态的处理容易被忽略,但答辩演示时如果推荐列表为空,页面一片空白会很尴尬。加一句引导文案既提升了体验,也暗示了推荐系统需要用户行为数据才能工作。

4.2 评分功能的实现与数据闭环

推荐系统的数据来源是用户评分,所以评分功能必须做好。用户在菜品详情页打分后,数据写入rating表,下次请求推荐时就能用到新数据。

@PostMapping("/rating") public Result<?> submitRating(@RequestBody RatingDTO dto) { // 参数校验 if (dto.getScore() < 1 || dto.getScore() > 5) { return Result.error("评分必须在1到5之间"); } // 查询是否已评分 Rating existing = ratingMapper.selectByUserAndFood(dto.getUserId(), dto.getFoodId()); if (existing != null) { // 更新已有评分 existing.setScore(dto.getScore()); ratingMapper.updateById(existing); } else { // 新增评分 Rating rating = new Rating(); rating.setUserId(dto.getUserId()); rating.setFoodId(dto.getFoodId()); rating.setScore(dto.getScore()); ratingMapper.insert(rating); } // 更新菜品的平均分 foodService.updateAvgScore(dto.getFoodId()); return Result.success(); }

这段逻辑用“先查后写”的方式处理重复评分。selectByUserAndFood利用rating表上的唯一索引uk_user_food做查询,如果已有记录就更新分数,没有就插入。最后调用updateAvgScore重新计算菜品的平均分,保证前端展示的分数和评分数据一致。

updateAvgScore的实现是一条 SQL:

UPDATE food f SET f.avg_score = ( SELECT ROUND(AVG(r.score), 1) FROM rating r WHERE r.food_id = f.id ) WHERE f.id = #{foodId}

用子查询算平均分并保留一位小数。如果某道菜没有任何评分,子查询返回 NULL,avg_score会被设为 NULL。可以在 SQL 里用IFNULL包一层,或者在 Java 里判断后设默认值 0。

4.3 跨域配置与 axios 拦截器

前后端分离项目绕不开跨域问题。开发阶段前端跑在 8080,后端跑在 8081,浏览器会拦截跨域请求。最直接的解决方案是在后端加 CORS 配置。

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

allowedOriginPatterns("*")在 SpringBoot 2.4 之后替代了allowedOrigins("*"),因为后者在allowCredentials(true)时会报错。maxAge(3600)表示预检请求的结果缓存一小时,减少 OPTIONS 请求次数。

前端 axios 也建议加一个拦截器统一处理 token 和错误:

// main.js 中配置 axios 拦截器 axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = 'Bearer ' + token; } return config; }); axios.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); window.location.href = '/login'; } return Promise.reject(error); } );

请求拦截器自动带上 token,响应拦截器统一处理 401 未登录状态。这样每个页面组件里不用重复写 token 逻辑,代码干净很多。

5. 避坑与排查:那些让项目跑不起来的常见问题

5.1 数据库连接报错:时区与驱动类名

现象:启动 SpringBoot 时报The server time zone value '???ú±ê×??±??' is unrecognized。

原因:MySQL 8.0 的 JDBC 驱动要求显式指定时区,不指定时会尝试用系统默认时区,中文 Windows 系统下会乱码导致解析失败。

解决:在application.yml的 JDBC URL 里加上serverTimezone=Asia/Shanghai:

spring: datasource: url: jdbc:mysql://localhost:3306/food_recommend?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false driver-class-name: com.mysql.cj.jdbc.Driver

同时注意驱动类名,MySQL 8.0 用com.mysql.cj.jdbc.Driver,5.x 用com.mysql.jdbc.Driver。用错会报ClassNotFoundException。

5.2 前端请求后端返回 404 但后端日志无记录

现象:Vue 页面请求/api/food/list返回 404,但后端控制台没有任何请求日志。

原因:大概率是 Vue 的开发服务器代理没配好,请求根本没发到后端。Vue CLI 默认把/api开头的请求当作前端路由处理。

解决:在vue.config.js里配置devServer.proxy:

module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '/api' } } } } };

changeOrigin: true让代理请求的 Host 头变成目标地址,避免后端做 Host 校验时拒绝。pathRewrite这里保持原样,因为后端接口本身就带/api前缀。如果后端接口没有/api前缀,才需要重写去掉。

5.3 协同过滤推荐结果为空或总是那几道菜

现象:调用推荐接口返回空列表,或者每次推荐的都是同样的热门菜品。

原因有三种可能:一是目标用户没有任何评分记录,算法冷启动无法计算相似度;二是 K 值设置不当,太小导致找不到足够邻居,太大导致热门菜品主导推荐;三是归一化步骤遗漏,热门菜品被过度推荐。

解决:冷启动问题可以在用户没有评分时返回“热门菜品”作为兜底,按avg_score降序取前 N 条。K 值从 10 开始调,观察推荐结果的多样性。归一化必须做,预测分数要除以相似度之和,否则评分人数多的菜品会占据推荐列表。

5.4 评分数据重复导致推荐偏差

现象:同一用户对同一道菜有多条评分记录,协同过滤计算时该菜品的权重被放大。

原因:rating表没有加唯一约束,或者代码里用insert而不是“先查后更新”。

解决:在rating表的(user_id, food_id)上建唯一索引,代码里用INSERT ... ON DUPLICATE KEY UPDATE或者先查后写。如果数据已经脏了,先跑一条去重 SQL:

DELETE r1 FROM rating r1 INNER JOIN rating r2 WHERE r1.id > r2.id AND r1.user_id = r2.user_id AND r1.food_id = r2.food_id;

这条 SQL 保留每组重复记录中 id 最小的那条,删除其余。

5.5 打包部署后前端页面空白

现象:npm run build后把dist目录放到 SpringBoot 的static下,访问页面空白,控制台报资源 404。

原因:Vue 默认打包的publicPath是/,如果项目部署在非根路径下,资源路径会不对。另外 Vue Router 的 history 模式需要后端配置 fallback,否则刷新页面会 404。

解决:在vue.config.js里设publicPath: './',让资源用相对路径。后端加一个配置,把非 API 请求转发到index.html:

@Controller public class SpaController { @RequestMapping(value = "/{path:[^\\.]*}") public String forward() { return "forward:/index.html"; } }

这样刷新/recommend这类前端路由时,后端会返回index.html,由 Vue Router 接管路由。

6. 让推荐结果更准的两个进阶技巧

6.1 用 ItemCF 替代 UserCF 提升稳定性

UserCF 的问题是用户口味会变,今天喜欢辣的明天可能想吃清淡的,用户相似度不稳定。ItemCF 计算的是菜品之间的相似度,菜品属性相对固定,推荐结果更稳定。

ItemCF 的核心逻辑是:如果很多用户同时喜欢菜品 A 和菜品 B,那么 A 和 B 相似。给用户推荐和他历史喜欢的菜品相似的菜。

/** * 计算菜品之间的相似度(基于用户评分) * @param allRatings userId -> (foodId -> score) * @return foodId -> (similarFoodId -> similarity) */ public Map<Integer, Map<Integer, Double>> computeItemSimilarity( Map<Integer, Map<Integer, Integer>> allRatings) { // 转置:foodId -> (userId -> score) Map<Integer, Map<Integer, Integer>> foodUserMap = new HashMap<>(); for (Map.Entry<Integer, Map<Integer, Integer>> userEntry : allRatings.entrySet()) { int userId = userEntry.getKey(); for (Map.Entry<Integer, Integer> rating : userEntry.getValue().entrySet()) { foodUserMap.computeIfAbsent(rating.getKey(), k -> new HashMap<>()) .put(userId, rating.getValue()); } } Map<Integer, Map<Integer, Double>> similarityMap = new HashMap<>(); List<Integer> foodIds = new ArrayList<>(foodUserMap.keySet()); for (int i = 0; i < foodIds.size(); i++) { int foodA = foodIds.get(i); Map<Integer, Integer> usersA = foodUserMap.get(foodA); similarityMap.putIfAbsent(foodA, new HashMap<>()); for (int j = i + 1; j < foodIds.size(); j++) { int foodB = foodIds.get(j); Map<Integer, Integer> usersB = foodUserMap.get(foodB); // 用余弦相似度计算菜品相似度 Set<Integer> commonUsers = new HashSet<>(usersA.keySet()); commonUsers.retainAll(usersB.keySet()); if (commonUsers.isEmpty()) continue; double dotProduct = 0, normA = 0, normB = 0; for (Integer userId : commonUsers) { int scoreA = usersA.get(userId); int scoreB = usersB.get(userId); dotProduct += scoreA * scoreB; normA += scoreA * scoreA; normB += scoreB * scoreB; } double similarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); similarityMap.get(foodA).put(foodB, similarity); similarityMap.computeIfAbsent(foodB, k -> new HashMap<>()).put(foodA, similarity); } } return similarityMap; }

这段代码先把“用户-菜品”评分矩阵转置成“菜品-用户”矩阵,然后两两计算菜品之间的余弦相似度。余弦相似度只关注评分向量的方向,对评分尺度不敏感,适合菜品相似度计算。计算量是 O(n²),菜品数量多的时候会慢,可以只计算有共同评分用户的菜品对,减少无效计算。

拿到菜品相似度矩阵后,给用户推荐时:遍历用户评分过的菜品,找到和每个菜品最相似的 K 个菜,加权求和预测用户对未评分菜品的兴趣度,排序取前 N。

6.2 用 Redis 缓存推荐结果降低响应时间

每次请求都实时计算推荐结果,在数据量增大后会明显变慢。一个实用的优化是把推荐结果缓存到 Redis,设置过期时间,用户评分后主动清除缓存。

@Autowired private RedisTemplate<String, Object> redisTemplate; public List<FoodVO> getRecommendWithCache(int userId, int n) { String cacheKey = "recommend:user:" + userId + ":n:" + n; // 1. 先查缓存 Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return (List<FoodVO>) cached; } // 2. 缓存未命中,实时计算 List<FoodVO> result = computeRecommend(userId, n); // 3. 写入缓存,过期时间30分钟 redisTemplate.opsForValue().set(cacheKey, result, 30, TimeUnit.MINUTES); return result; } // 用户提交评分后调用此方法清除缓存 public void clearRecommendCache(int userId) { Set<String> keys = redisTemplate.keys("recommend:user:" + userId + ":*"); if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); } }

缓存 key 的设计包含用户 ID 和推荐数量,因为不同 n 值的结果不同。过期时间设 30 分钟是个折中——太短缓存效果不明显,太长用户评分后推荐更新不及时。用户提交评分后主动清除该用户的缓存,保证下次请求能拿到最新推荐。

Redis 的配置在application.yml里:

spring: redis: host: localhost port: 6379 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0

如果本地没装 Redis,可以用 Docker 快速起一个:docker run -d -p 6379:6379 redis:7-alpine。毕业设计答辩时如果不想引入 Redis 依赖,也可以先用ConcurrentHashMap做本地缓存,效果类似但重启后失效。

6.3 答辩演示前必须检查的五个点

第一,数据库要有足够的测试数据。至少准备 20 个用户、50 道菜、200 条评分记录,否则协同过滤算出来的相似度矩阵太稀疏,推荐结果没有说服力。

第二,准备两个不同口味的测试账号。一个账号多给辣菜高分,另一个多给甜菜高分,演示时对比两个账号的推荐结果差异,能直观展示推荐算法在工作。

第三,检查所有接口的异常处理。演示时如果某个接口报 500 错误,页面直接白屏,很影响观感。至少给每个 Controller 方法加 try-catch,返回友好的错误提示。

第四,提前跑一遍完整流程:注册 → 登录 → 浏览菜品 → 评分 → 查看推荐。确保每一步都顺畅,没有卡顿或报错。

第五,把项目打包成可执行 jar 和前端 dist 目录,在答辩电脑上实际部署一次。不要等到现场才第一次打包,环境差异可能导致意外问题。

我自己做这类项目最大的教训是:不要等到最后一周才开始联调。前后端分开开发时各自都能跑,合在一起经常出现跨域、参数格式、日期序列化这些问题。提前两周开始联调,留出足够时间修 bug,比最后熬夜强得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询