尧图精选

SpringBoot+Vue美食推荐系统毕设:协同过滤算法落地与避坑指南

🕒 发布时间:2026/10/1 1:24:42 📁 来源:尧图网络
简介这份资源是面向计算机相关专业学生与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 直接跑。答辩演示的时候不需要在评委电脑上装 Tomcatjava -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 typemaven-project \ -d languagejava \ -d bootVersion2.7.18 \ -d groupIdcom.example \ -d artifactIdfood-recommend \ -d namefood-recommend \ -d packageNamecom.example.foodrecommend \ -d dependenciesweb,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 serveaxios用于和后端 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 美食/菜品表 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 用户评分表推荐算法的核心数据来源 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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(MapInteger, Integer userRatings1, MapInteger, Integer userRatings2) { // 找出两个用户共同评分的菜品 SetInteger 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_idvalue 是score。实际查询时用 MyBatis 的selectByUserId方法返回ListRating再在 Java 里转成 Map。3.3 基于相似用户生成推荐列表有了相似度计算方法下一步是给目标用户生成推荐。思路是找到和目标用户最相似的 K 个用户把他们评分高但目标用户没吃过的菜推荐出来。/** * 基于 UserCF 为目标用户生成推荐 * param targetUserId 目标用户ID * param allRatings 所有用户的评分数据userId - (foodId - score) * param k 选取最相似的K个用户 * param recommendN 推荐N道菜 * return 推荐菜品ID列表按预测评分从高到低排序 */ public ListInteger recommendByUserCF(int targetUserId, MapInteger, MapInteger, Integer allRatings, int k, int recommendN) { MapInteger, Integer targetRatings allRatings.get(targetUserId); if (targetRatings null || targetRatings.isEmpty()) { return Collections.emptyList(); // 目标用户无评分记录冷启动 } // 1. 计算目标用户与其他所有用户的相似度 ListUserSimilarity similarities new ArrayList(); for (Map.EntryInteger, MapInteger, 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)); ListUserSimilarity topK similarities.subList(0, Math.min(k, similarities.size())); // 3. 加权预测目标用户对未评分菜品的分数 MapInteger, Double predictedScores new HashMap(); MapInteger, Double simSumMap new HashMap(); for (UserSimilarity us : topK) { MapInteger, Integer neighborRatings allRatings.get(us.userId); for (Map.EntryInteger, 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. 归一化并排序 ListMap.EntryInteger, Double finalScores new ArrayList(); for (Map.EntryInteger, 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 ResultListFoodVO recommendForUser( PathVariable int userId, RequestParam(defaultValue 10) int n) { // 1. 从数据库加载所有评分数据 MapInteger, MapInteger, Integer allRatings ratingService.loadAllRatings(); // 2. 调用推荐算法 ListInteger foodIds recommendByUserCF(userId, allRatings, 15, n); // 3. 根据菜品ID查询详细信息 ListFoodVO foods foodService.findByIds(foodIds); return Result.success(foods); } }loadAllRatings()方法从rating表一次性查出所有数据在内存里构建MapInteger, MapInteger, 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 classrecommend-page div v-ifloading classloading-box i classel-icon-loading/i 正在为你推荐... /div div v-else-ifrecommendList.length 0 classempty-box 暂无推荐先去评分几道菜吧 /div div v-else classfood-grid el-card v-forfood in recommendList :keyfood.id classfood-card img :srcfood.imageUrl classfood-img / div classfood-name{{ food.name }}/div div classfood-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的实现是一条 SQLUPDATE 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}用子查询算平均分并保留一位小数。如果某道菜没有任何评分子查询返回 NULLavg_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 里加上serverTimezoneAsia/Shanghaispring: datasource: url: jdbc:mysql://localhost:3306/food_recommend?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse driver-class-name: com.mysql.cj.jdbc.Driver同时注意驱动类名MySQL 8.0 用com.mysql.cj.jdbc.Driver5.x 用com.mysql.jdbc.Driver。用错会报ClassNotFoundException。5.2 前端请求后端返回 404 但后端日志无记录现象Vue 页面请求/api/food/list返回 404但后端控制台没有任何请求日志。原因大概率是 Vue 的开发服务器代理没配好请求根本没发到后端。Vue CLI 默认把/api开头的请求当作前端路由处理。解决在vue.config.js里配置devServer.proxymodule.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或者先查后写。如果数据已经脏了先跑一条去重 SQLDELETE 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.htmlController 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 MapInteger, MapInteger, Double computeItemSimilarity( MapInteger, MapInteger, Integer allRatings) { // 转置foodId - (userId - score) MapInteger, MapInteger, Integer foodUserMap new HashMap(); for (Map.EntryInteger, MapInteger, Integer userEntry : allRatings.entrySet()) { int userId userEntry.getKey(); for (Map.EntryInteger, Integer rating : userEntry.getValue().entrySet()) { foodUserMap.computeIfAbsent(rating.getKey(), k - new HashMap()) .put(userId, rating.getValue()); } } MapInteger, MapInteger, Double similarityMap new HashMap(); ListInteger foodIds new ArrayList(foodUserMap.keySet()); for (int i 0; i foodIds.size(); i) { int foodA foodIds.get(i); MapInteger, Integer usersA foodUserMap.get(foodA); similarityMap.putIfAbsent(foodA, new HashMap()); for (int j i 1; j foodIds.size(); j) { int foodB foodIds.get(j); MapInteger, Integer usersB foodUserMap.get(foodB); // 用余弦相似度计算菜品相似度 SetInteger 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 RedisTemplateString, Object redisTemplate; public ListFoodVO getRecommendWithCache(int userId, int n) { String cacheKey recommend:user: userId :n: n; // 1. 先查缓存 Object cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return (ListFoodVO) cached; } // 2. 缓存未命中实时计算 ListFoodVO result computeRecommend(userId, n); // 3. 写入缓存过期时间30分钟 redisTemplate.opsForValue().set(cacheKey, result, 30, TimeUnit.MINUTES); return result; } // 用户提交评分后调用此方法清除缓存 public void clearRecommendCache(int userId) { SetString 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比最后熬夜强得多。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →