
简介这是一套基于SpringBootVue的协同过滤旅游推荐系统项目定位为毕业设计、课程设计、大作业或初期项目实训的完整参考。系统采用前后端分离设计后端以Java提供协同过滤推荐接口与业务逻辑前端用Vue构建交互界面可帮助学习者理解从数据采集、相似度计算到推荐结果落地的完整流程。压缩包共341个文件、约11.3MB涵盖后端Java源文件、前端Vue组件与静态资源、SQL数据库脚本及配置文件目录结构清晰便于按模块查阅。运行环境为MySQL 5.7与JDK 1.8附带可运行源码与数据库文件导入开发工具后可直接调试。目前已有842人学习下载适合希望掌握前后端分离实战、梳理推荐算法工程实现的学习者也可在现有功能上修改扩展支撑答辩展示或二次开发。1. 这到底是个什么项目SpringBoot Vue 的旅游推荐系统拆解如果你是在准备毕业设计或者想找一个能完整跑通前后端、又带点算法亮点的项目源码这个名为「基于 SpringBoot Vue 的协同过滤算法旅游推荐系统」的 zip 包算是比较典型的选题方向。它不是一个只做增删改查的管理系统而是把推荐算法真正嵌到了业务里用户登录、浏览景点、对景点评分系统再根据这些行为数据用协同过滤算法给用户生成一份个性化的景点推荐列表。适合的人群很明确——想学 SpringBoot 和 Vue 前后端分离怎么做、又想搞懂推荐算法怎么落地的人。这类项目的关键不在页面漂不漂亮而在三个环节算法能不能算得动、接口能不能查得快、前端能不能把结果讲清楚。本文按做这类项目最常见的工程路径展开从协同过滤选型、后端实现、前端联调到参数调优和排错给你一条可以直接照着复现的路线。2. 协同过滤在旅游场景怎么落地从用户-物品矩阵到推荐结果2.1 基于用户的协同过滤找相似的人推他们爱去的地方协同过滤算法分两类基于物品Item-based和基于用户User-based。旅游推荐系统里这两者都有应用场景但“用户-景点”的行为矩阵通常是稀疏的——一个人一年去过的景点可能就十几个全站景点却有几百个。基于用户的协同过滤User-based CF的核心逻辑是找出与当前用户行为最相似的 K 个用户把这 K 个人喜欢但当前用户没去过的景点推荐出来。为什么旅游场景常用 User-based 而不是 Item-based因为景点的“相似性”很难用行为数据算准。两个景点被同一个人去过不代表它们真的相似——可能只是因为一次行程顺路。而用户的偏好则相对稳定喜欢人文历史的用户大概率持续给博物馆、古建筑类景点打高分。实现上需要构造一个“用户-景点”评分矩阵。行是用户列是景点值是评分。这个矩阵通常用 Map 或二维数组存储在数据量不大几千用户、几百景点时全量计算完全可行。# 用户-景点评分矩阵示例行是用户ID列是景点ID # 0 表示该用户未评分该景点 rating_matrix { 1: {101: 5, 102: 3, 103: 0, 104: 4}, 2: {101: 4, 102: 0, 103: 5, 104: 0}, 3: {101: 0, 102: 2, 103: 4, 104: 5}, }这个矩阵是协同过滤的输入。注意“0 表示未评分”和“评了 1 分”是有本质区别的前者是缺失值后者是明确差评。计算相似度时只能取双方都有评分的景点维度不能把 0 当真实评分算进去——这是新手最容易犯的错。2.2 相似度计算的三种选择余弦、皮尔逊、杰卡德怎么选确定了用 User-based CF下一步就是算用户之间的相似度。常见做法有三种各有适用场景。余弦相似度Cosine Similarity是最常用的。它把每个用户的评分看成高维空间的一个向量计算两个向量夹角的余弦值。优点是不受用户评分尺度差异影响——一个习惯打 5 分的人和一个习惯打 3 分的人只要评分趋势一致相似度仍然能算出来。皮尔逊相关系数Pearson Correlation则更进一步它对用户评分做了中心化处理减去均值能消除用户评分偏置的影响。杰卡德相似度Jaccard Similarity只看共同评分的景点数量不看具体分值适合行为数据非常稀疏的场景。实际项目里我一般会用皮尔逊或余弦杰卡德作为冷启动时的补充。下面是一个最小实现import math def pearson_similarity(user1_ratings, user2_ratings): # 只取双方都有评分的景点 common set(user1_ratings.keys()) set(user2_ratings.keys()) if len(common) 2: return 0.0 # 分别计算两个用户的平均分只基于共同评分 avg1 sum(user1_ratings[k] for k in common) / len(common) avg2 sum(user2_ratings[k] for k in common) / len(common) num sum((user1_ratings[k] - avg1) * (user2_ratings[k] - avg2) for k in common) den1 math.sqrt(sum((user1_ratings[k] - avg1) ** 2 for k in common)) den2 math.sqrt(sum((user2_ratings[k] - avg2) ** 2 for k in common)) if den1 0 or den2 0: return 0.0 return num / (den1 * den2)这段代码的注意点在于common集合的过滤。这里有个权衡共同评分只有 1 个时相关系数要么是 1 要么是 -1完全没有统计意义只有 2 个时也极不稳定。所以代码里要求至少 2 个共同评分实际项目中建议至少 5 个。另外如果两个用户都只给同一个景点打了分den1或den2会计算为 0需要返回 0 而不是抛异常。2.3 冷启动问题新用户没有行为数据怎么办协同过滤的天然短板是冷启动。一个新注册的用户没有任何评分和浏览记录系统根本不知道他是喜欢自然风光还是人文古迹。这时候算法层面救不了要靠业务策略兜底。常见的兜底方案有三种一是按全局热度推荐把全站评分最高、访问量最大的景点推给新用户二是按地区推荐如果系统采集了用户注册时的 IP 所在城市就优先推本地周边景点三是混合策略前 N 个推荐给热门后面穿插一些随机景点做探索——这也是推荐系统里经典的“探索与利用”问题。在实际代码里这两种策略通常写在同一个推荐接口里如果用户在评分表里没有任何记录就直接返回热门景点列表有记录的走协同过滤。这个判断逻辑必须在后端做不能等前端传了 userId 再从空列表开始拼。3. 用 SpringBoot 把推荐算法包成可调用的服务3.1 项目结构与核心表设计用户、景点、评分、行为日志这个标题的 zip 包解压后典型的结构是一个 Maven 多模块或单模块的 SpringBoot 工程前端是独立的 Vue 工程。Java 后端负责提供接口、管理数据、跑推荐算法Vue 前端负责页面展示和交互。表设计是整个系统的地基。基于协同过滤的旅游推荐系统核心表至少四张用户表sys_user、景点表scenic_spot、评分表user_rating、行为日志表user_behavior。评分表记录用户对景点的显式评分如 1-5 星行为日志表记录浏览、搜索、收藏等隐式行为。如果源码里有“猜你喜欢”的推荐列表它的数据来源就是这两张表的结合。-- 用户-景点评分表 CREATE TABLE user_rating ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户ID, scenic_id BIGINT NOT NULL COMMENT 景点ID, rating TINYINT NOT NULL COMMENT 评分 1-5, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_scenic (user_id, scenic_id) );这里有一个关键设计联合唯一键uk_user_scenic。用户的重复评分要么被拦截要么用ON DUPLICATE KEY UPDATE做覆盖。否则同一条评分记录出现多行协同过滤算相似度时会把同一个景点算多次结果直接翻车。景点表至少要有景点名称、所在城市、景点介绍、封面图 URL 这些字段——推荐接口返回的不能只是 ID 列表前端要展示卡片需要完整的景点信息。3.2 数据源初始化从 CSV 导入评分数据的完整流程空系统没有数据推荐算法跑不出结果。这个项目里数据源初始化有两种路径一种是程序启动时用CommandLineRunner或ApplicationRunner自动导入另一种是管理后台提供导入功能。不管哪种底层逻辑都是读取 CSV 文件解析后写入评分表。Component public class RatingDataInitializer implements CommandLineRunner { Autowired private JdbcTemplate jdbcTemplate; Override public void run(String... args) throws Exception { // 检查评分表是否已有数据避免重复导入 Integer count jdbcTemplate.queryForObject( SELECT COUNT(*) FROM user_rating, Integer.class); if (count 0) { return; } // 读取 classpath 下的 CSV 文件 ClassPathResource resource new ClassPathResource(ratings.csv); try (BufferedReader reader new BufferedReader( new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { String line; // 第一行是表头跳过 reader.readLine(); while ((line reader.readLine()) ! null) { String[] parts line.split(,); if (parts.length ! 3) { continue; } jdbcTemplate.update( INSERT INTO user_rating (user_id, scenic_id, rating) VALUES (?, ?, ?), Long.parseLong(parts[0].trim()), Long.parseLong(parts[1].trim()), Integer.parseInt(parts[2].trim())); } } } }这段代码要说明两个点一是count 0的判断是幂等保护SpringBoot 应用重启时会再次执行run方法如果没有这个判断评分表每次重启都会追加一份重复数据二是 CSV 文件必须放在src/main/resources目录下打包后它会出现在 classpath 的根目录里ClassPathResource才能找到它。3.3 推荐接口实现接收 userId返回景点列表的代码走读推荐服务是整个后端最核心的一层。它要做的事是拉取所有用户评分数据 → 计算目标用户与其他用户的相似度 → 取 TopK 相似用户 → 聚合他们评分高的景点 → 过滤掉目标用户已去过的 → 排序返回。Service public class RecommendService { Autowired private UserRatingMapper ratingMapper; Autowired private ScenicSpotMapper scenicMapper; public ListScenicSpot recommend(Long userId, int topK, int limit) { // 1. 如果用户没有评分返回热门景点兜底 ListUserRating myRatings ratingMapper.findByUserId(userId); if (myRatings.isEmpty()) { return scenicMapper.findTopRated(limit); } // 2. 拉取所有用户的评分记录 ListUserRating allRatings ratingMapper.findAll(); // 3. 计算目标用户与其他用户的相似度 MapLong, Double simMap new HashMap(); MapLong, ListUserRating ratingsByUser allRatings.stream() .collect(Collectors.groupingBy(UserRating::getUserId)); for (Map.EntryLong, ListUserRating entry : ratingsByUser.entrySet()) { Long otherUserId entry.getKey(); if (otherUserId.equals(userId)) { continue; } double sim calculatePearson(myRatings, entry.getValue()); if (sim 0.3) { // 相似度低于阈值的不考虑 simMap.put(otherUserId, sim); } } // 4. 按相似度排序取 TopK ListLong similarUserIds simMap.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(topK) .map(Map.Entry::getKey) .collect(Collectors.toList()); // 5. 聚合推荐结果加权评分 SetLong visitedScenicIds myRatings.stream() .map(UserRating::getScenicId) .collect(Collectors.toSet()); return similarUserIds.stream() .flatMap(id - ratingsByUser.get(id).stream()) .filter(r - !visitedScenicIds.contains(r.getScenicId())) .collect(Collectors.groupingBy( UserRating::getScenicId, Collectors.averagingDouble(r - r.getRating() * simMap.get(r.getUserId())))) .entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(limit) .map(entry - scenicMapper.findById(entry.getKey())) .collect(Collectors.toList()); } }参数说明topK是相似用户数通常取 10 到 30 之间limit是最终返回的推荐景点数常见值是 10 或 12一屏两行卡片。相似度阈值 0.3 是个经验值数据稀疏时建议放宽到 0.1数据稠密时可以调到 0.5。注意这里的加权方式是“评分 × 相似度”比单纯取平均更合理——高相似用户的高分景点应该排在更前面。4. Vue 前端怎么把推荐结果讲成故事4.1 页面骨架与路由首页、景点列表、推荐结果页Vue 前端部分路由设计决定用户怎么逛这个系统。常见的项目结构是首页推荐列表、景点列表页全部景点、景点详情页、登录注册页、个人中心页。其中首页是重中之重用户登录后第一眼看到的就是推荐结果。// src/router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: Home, component: () import(/views/Home.vue) }, { path: /scenic/:id, name: ScenicDetail, component: () import(/views/ScenicDetail.vue) }, { path: /login, name: Login, component: () import(/views/Login.vue) }, { path: /profile, name: Profile, component: () import(/views/Profile.vue) }, ] const router createRouter({ history: createWebHistory(), routes, }) export default router这里用了动态导入() import(...)实现路由懒加载——首屏只加载首页组件其他页面等到真正访问时才加载。这个项目如果部署时把前端 build 后的静态文件丢进 SpringBoot 的static目录history 模式有个经典坑直接刷新/scenic/1会 404。解决办法是在 SpringBoot 里加一个 fallback 路由把所有非/api/路径的重定向到index.html否则用户一刷新就白屏。4.2 调后端接口并渲染推荐卡片axios 与组件的配合前端调推荐接口的链路是页面挂载后从 sessionStorage 或 Pinia 里取 userId调/api/recommend接口拿到景点数组用 v-for 渲染卡片。template div classrecommend-list div v-foritem in scenicList :keyitem.id classscenic-card img :srcitem.coverUrl :altitem.name / h3{{ item.name }}/h3 p classrating评分{{ item.avgRating }}/p p classdesc{{ item.intro }}/p /div /div /template script setup import { ref, onMounted } from vue import axios from axios const scenicList ref([]) onMounted(async () { const userId sessionStorage.getItem(userId) if (!userId) { // 未登录直接跳转到登录页 return } const response await axios.get(/api/recommend, { params: { userId, topK: 20, limit: 10 } }) scenicList.value response.data.data }) /script注意这里的sessionStorage.getItem(userId)只是个示例真实项目里更稳妥的做法是后端接口从登录态里拿用户身份而不是信任前端传来的 userId——否则任何人手动改一下参数就能拿到别人的推荐列表。推荐接口本身是无状态的只依赖 userId 和评分数据这意味着服务端接口一次只能算一个用户的数据如果用户量上来每个请求都要遍历全量评分数据性能会成为瓶颈。4.3 与 SpringBoot 联调的跨域配置与请求封装前端开发时跑在 5173 端口Vite 默认后端跑在 8080 端口跨域问题躲不掉。两个解决思路开发环境用 Vite 的 proxy 代理生产环境在 SpringBoot 里配置全局跨域。// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, // 路径不需要重写后端接口本身就是 /api/** } } } })proxy 的changeOrigin: true很关键。它把请求头里的Host改成目标地址后端如果有基于 Host 的校验就不会报错。生产环境如果前后端分开部署比如后端在 Nginx 后面的 8080前端在另一个端口再去 Nginx 或 SpringBoot 里配置跨域。这类项目最常见的问题不是跨域配置本身而是前端请求的路径和后端实际接口路径对不上后端接口是/api/recommend/list前端写成了/recommend/list一调就是 404然后怀疑跨域配置有问题折腾半天。5. 参数调优与避坑协同过滤最容易翻车的 5 个地方5.1 评分矩阵太稀疏相似度计算结果全是 0现象推荐接口能跑通但返回结果全是热门兜底协同过滤分支没生效。原因用户评分数据太少。比如 500 个用户每个只评了 2-3 个景点任意两个用户的共同评分交集可能为 0皮尔逊相似度全部返回 0。相似度矩阵全是 0推荐结果自然为空。解决先看数据量再谈算法。我一般会用一条 SQL 检查评分覆盖率——SELECT COUNT(DISTINCT user_id), COUNT(DISTINCT scenic_id), COUNT(*) FROM user_rating如果每个用户平均评分不足 10 条要么加大评分表的种子数据要么把算法里的相似度函数从皮尔逊换成杰卡德——杰卡德只需要共同评分的数量不需要分值稀疏数据下能捞回更多相似用户。5.2 SpringBoot 版本与依赖冲突启动直接报错现象zip 包里的前端项目npm install和npm run dev能跑后端一启动就报依赖冲突或者启动成功但访问接口 404。原因这类项目最常见的坑是 SpringBoot 版本号的问题。spring-boot-starter-parent 版本太高比如 3.x和项目里用的 mybatis-plus、shiro 等老依赖版本不兼容或者 JDK 版本太新17导致某些反射代码直接报IllegalAccessException。Vue 项目这边常见的坑是 node_modules 安装失败比如 Node 版本和 Vite 要求的版本不匹配。解决优先用 zip 包自带的pom.xml内 SpringBoot 版本对应的 JDK 版本。如果确定要升级 SpringBoot先升级核心 starter再逐项验证 mybatis、redis、jwt 等依赖的兼容性。前端如果npm install报ERESOLVE错误用npm install --legacy-peer-deps绕过。5.3 推荐结果全是热门景点看不到个性现象协同过滤算法跑起来了但排名靠前的永远是那几个评分最高的热门景点不同用户看到的推荐列表几乎一样。原因热门景点评分基数大加权后天然排前面。这在推荐系统里叫“马太效应”算法层面没有做去热门化处理。这个问题在旅游场景尤其明显——故宫、张家界这种知名景点谁都评分相似用户又都去过它们会被重复推荐。解决常见的做法是加一个时间衰减因子用户评分越久权重越低、对结果做流行度惩罚。最简单的实现最终得分乘以一个惩罚系数热门景点系数小冷门景点系数大。冷门景点评分数量少均值波动大需要再用置信度下限Bayesian Average平滑一下。5.4 前端传参错误导致推荐结果为空现象前端能调到接口但返回的data是空数组。原因这类项目最容易出现的问题是 userId 没取到前端传了undefined后端接收时转Long失败返回 500 或者把 userId 当 0 处理查评分表查不到走了空数据分支返回空列表。解决前后端联调时不要只看响应状态码要看实际参数传递。Firefox 或 Chrome 的开发者工具里 Network 面板看请求的 Query String Parameters 是否正确。后端接口里最好加一个参数校验userId为 null 时直接返回 400 和明确错误信息——空数据响应和参数错误响应混在一起前端根本没法定位问题。5.5 数据量大之后接口越来越慢从 50ms 涨到 3 秒现象评分表只有 1 万条时接口秒回涨到 50 万条后每次推荐请求都要两三秒系统动不动卡住。原因推荐算法在 Service 层里把所有评分数据ratingMapper.findAll()一次性查出来每一行都是一个 Java 对象50 万条数据全量加载到内存再加上流式计算和排序GC 压力很大。每次请求都全量计算没有缓存。解决常见的优化三步走——先加本地缓存Caffeine热门用户的推荐结果缓存 5 分钟再把相似度计算改成离线预计算用定时任务每天凌晨算好每个用户的 TopK 相似用户存表推荐请求只查表做聚合最后数据库层面确保评分表的user_id、scenic_id都建了索引。这一步做完接口基本能回到百毫秒内。6. 让推荐更可信离线评估与结果验证技巧推荐系统做完不是能出结果就算完你还要能证明它推荐得“准”。毕业设计答辩或者项目汇报时这一套验证方法比单纯演示页面更有说服力。离线评估的核心是把评分数据集切分成训练集和测试集——80% 的数据用来训练算法计算相似度矩阵20% 的数据用来验证即把测试集里的用户-景点评分对隐藏掉让算法去预测这些景点的评分然后对比预测值和真实值的误差。# 评估指标使用 RMSE 衡量预测评分与真实评分的偏差 import numpy as np predicted [4.2, 3.8, 5.0, 2.9] actual [5.0, 4.0, 3.0, 3.0] rmse np.sqrt(np.mean((np.array(predicted) - np.array(actual)) ** 2)) print(fRMSE: {rmse:.4f})RMSE均方根误差的值越小代表预测越准。如果测试集上 RMSE 大于 1.5说明你的相似度计算有问题或者数据太稀疏导致预测均值回归严重。另一个更好向人解释的指标是 PrecisionN在推荐列表的前 10 个景点里有多少是用户真正去过的评分过的。如果一个用户测试集里有 5 个景点推荐列表 10 个里命中了 2 个Precision10 就是 20%。调参的时候我习惯只动一个变量比如固定 topK20只调整相似度阈值从 0.1 到 0.5 间隔 0.1 跑五组离线实验看哪组 RMSE 最低。然后再固定最优阈值调 topK。这样得到的最优参数组合虽然不是全局最优但比瞎猜靠谱得多而且答辩时说不出来。最后分享一个我自己踩过的教训做过一个版本为了给用户“惊喜感”在推荐列表里强行加入随机景点结果一个用户测下来体验不错十个人测完反馈都说“这推荐不太懂我”。后来才意识到随机探索是要基于沉默反馈来调权的——用户点了推荐里不感兴趣的景点、或者系统反复推荐他浏览过但没评分的地方这些都该让算法“反思”而不是做随机穿插。希望这个项目的实践能帮你避开同类问题这方向做起来真的能学到不少东西希望帮到你。本文还有配套的精品资源点击获取