☰
基于SpringBoot+Vue的协同过滤旅游推荐系统搭建与避坑指南
2026/10/7 22:37:29 网站建设 项目流程

简介:这是一份基于SpringBoot与Vue前后端分离架构的协同过滤旅游推荐系统源码,面向需要完成毕业设计、课程设计或Java全栈项目实训的开发者。项目结合后端SpringBoot服务与前端Vue界面,实现景点推荐、用户交互等核心功能,适合学习协同过滤算法落地、RESTful接口设计及前后端联调。压缩包内包含项目完整源码与SQL数据库脚本,共341个文件,其中89个Java文件承载后端业务逻辑,68个Vue文件构成前端页面,另有40个JavaScript、19个CSS及若干xml配置、图片素材、项目说明文档等,整体约11.3MB。使用需基于JDK1.8、MySQL5.7、Maven3.3.9及Tomcat7环境。资源目前已有842人学习,具备较高的参考价值,可直接导入IDE运行,也可作为算法推荐类项目的基础进行修改与二次开发。

1. 从标题说起:这个zip里装的是一个可运行的推荐系统骨架

接手一个名为“4b008-基于springboot+vue的协同过滤算法旅游推荐系统.zip”的工程包,解压后就是一个前后端分离的推荐系统:后端Spring Boot提供接口,前端Vue渲染页面,中间用协同过滤算法把用户收藏过的景点映射成“你可能也想去”的推荐列表。这类系统解决的问题很实际:一个游客收藏了灵隐寺,系统要能推荐出法喜寺、西溪湿地,而不是让他自己翻几十页游记。适合谁呢?想在毕设或内网业务里快速搭一套推荐流程的人,前端、后端、算法三块都有现成落点。先说结论:这个工程真正难的不是把Spring Boot启动起来,也不是Vue页面渲染,而是让离线的相似度矩阵和在线的用户行为保持一致,后面的每一章都在处理这件事。

2. 协同过滤在旅游推荐里怎么落地:为什么先选ItemCF,评分矩阵怎么搭

2.1 旅游场景首选ItemCF:低频消费决定了UserCF不可靠

协同过滤分两类:基于用户(UserCF)和基于物品(ItemCF)。旅游行为有一个明显特征:用户一年可能只出游两三次,很多人收藏过的景点不超过十个,这意味着用户-物品矩阵极端稀疏。UserCF要计算用户之间的相似度,稀疏矩阵下两个用户几乎没有任何交集,算出来的邻居不准,推荐就变成玄学。而ItemCF算的是物品之间的相似度,景点总数相对稳定,一个城市几百个POI,每个景点都可以被多个游客点评过,矩阵要稠密得多。所以在旅游推荐里,ItemCF基本是最稳的起步选择。

还有一个业务上的理由:可解释性。ItemCF给用户的解释是“因为你收藏了西湖,所以推荐曲院风荷”,用户一眼能看懂;UserCF的解释是“和你相似的用户还去了……”,旅游决策是低频高客单价行为,用户对“谁谁去了”的信任感远不如“它俩确实像”来得直接。我一般会给第一版直接定ItemCF,上线跑一阵再考虑混合策略。

2.2 行为日志到评分矩阵:加权打分、时间衰减与数据清洗

ItemCF需要一个评分矩阵。旅游系统没有真实评分时,要从行为日志构造。最常见的做法是给行为定权重:浏览给1分,收藏给3分,下单或购票给5分。为什么收藏权重比浏览高这么多?因为旅游用户逛景点页经常是随手点击,真正收藏说明有出行意愿,权重差太小会把收藏信号淹没。

时间因素必须参与,否则半年前的收藏和昨天的收藏权重一样,推荐会滞后。常见做法是以天为单位,每7天乘一个衰减系数0.9,表示一周前的行为影响力打九折;如果用户群体是半年不动的低频旅行者,把系数调到0.8更激进。下面这段Python脚本是离线流程里的核心部分,我习惯在数据分析阶段先跑一遍,确认矩阵不空再写Java版本。

import pandas as pd # 行为日志字段:user_id, item_id, behavior, ts df = pd.read_csv("behavior_log.csv") # 1. 行为权重,浏览=1 收藏=3 下单=5,权重的比例最终影响推荐 weight_map = {"view": 1.0, "fav": 3.0, "order": 5.0} df["score"] = df["behavior"].map(weight_map) # 2. 时间衰减:以最新一条日志为基准,每 7 天打 9 折 df["ts"] = pd.to_datetime(df["ts"]) latest = df["ts"].max() df["days"] = (latest - df["ts"]).dt.days df["decay"] = 0.9 ** (df["days"] // 7) df["score"] = df["score"] * df["decay"] # 3. 同一用户对同一景点可能有多条行为,先聚合再透视 agg = df.groupby(["user_id", "item_id"], as_index=False)["score"].sum() matrix = agg.pivot_table(index="user_id", columns="item_id", values="score").fillna(0) matrix.to_csv("user_item_matrix.csv")

这段逻辑分三步:score由行为权重乘时间衰减得到;groupby把同一用户和景点的多条行为累加成一条,再pivot成用户-物品矩阵;fillna(0)把空位补成0,保证后面算余弦相似度时不报错。有一点要注意,fillna(0)只是让矩阵能计算,它不等于用户真实评分为0,所以后面算相似度用余弦夹角,能抵消一部分评分数值大小的影响。

行为权重map和衰减系数0.9是最先要调的两个参数。如果业务上下单率极低,可以把order的权重调到8,让转化行为在推荐里更有发言权;如果发现推荐结果全集中在老景点,就把衰减系数调成0.7试试。数据清洗这步也容易被忽略:过滤行为时长小于3秒的view、过滤测试账号、按user_id和item_id去重,这些规则能防止爬虫和误触把整条相似度链带偏。

2.3 相似度矩阵存储与更新:剪枝后灌Redis,定时任务每天重算

算完评分矩阵后,下一步算物品相似度。常见做法是用余弦相似度对每个景点找Top20相似景点,这张矩阵离线算好,运行时不重算。更新节奏是每天凌晨跑一次批处理,把结果写入Redis。选Redis是因为推荐接口要把“根据一个景点找相似景点”变成O(1)查询,ZSET天然支持按分数倒序取前N个。

存储方案适用规模更新方式优点缺点
JSON文件放resources几百个物品重启加载最简单不可热更新
MySQL相似度表几千个物品定时任务全量刷新SQL好排查查询慢,要加缓存
Redis ZSET几万个物品每天全量写入接口响应快多一套运维

我一般建议直接用Redis ZSET,key命名成sim:{itemId},value是相似景点ID和相似度分数。离线算好后导出CSV,Java端用定时任务读取并写入Redis。文件里只保留相似度大于0.05的边,不然全量N乘N矩阵会膨胀得很厉害:5000个景点的全量矩阵是2500万行,剪枝后可能只剩几十万行,写入速度和查询效率都完全跟得上。

注意:相似度剪枝阈值不要设太高,0.05级别比较稳。阈值太高会让冷门景点彻底失去相似邻居,推荐列表会更早退化成热门榜。

3. Spring Boot后端:推荐接口三段式与Redis缓存策略

3.1 解压后的工程结构:前端后端分目录,接口和算法分层

这种zip包解压后,目录几乎都是同一套布局:backend放Spring Boot,frontend放Vue,少数把dist打包进static后合并成单工程。一个“基于springboot vue的项目”典型结构是这样的:

目录/文件职责
backend/controller接收参数、封装统一返回
backend/service推荐逻辑、兜底逻辑
backend/repository数据访问,MyBatis-Plus Mapper或JPA
backend/entity数据表实体
backend/configRedis、定时任务配置
frontend/src/views页面:推荐页、详情页
frontend/src/components景点卡片、加载状态
frontend/src/router路由配置

这个结构的核心原则是controller薄、service厚。推荐接口的并发量一般不大,但逻辑要分清楚:controller只做参数校验和结果包装,相似度查询、结果聚合、热门兜底全部放在service里。这样后面换算法或调参时不用动接口定义,接收方拿到代码也容易定位逻辑。

3.2 三张核心表:用户表、景点表、行为日志表

后端实现从表结构开始。旅游推荐最少需要三张表:用户表、景点信息表、用户行为日志表。景点表里的city、tags、heat三个字段都是为了后面兜底推荐和内容冷启动准备的,没有它们就只能硬推热门。

CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, nickname VARCHAR(50) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE spot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, city VARCHAR(20) NOT NULL, -- 同一城市的景点优先形成关联 rate DECIMAL(2,1) DEFAULT 0, -- 景区评分 0.0~5.0 tags VARCHAR(255) DEFAULT '', -- 逗号分隔:寺庙,古建筑,5A heat INT DEFAULT 0 -- 热度值,兜底排序用 ); CREATE TABLE behavior_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, item_id BIGINT NOT NULL, behavior VARCHAR(10) NOT NULL, -- view / fav / order created_at DATETIME NOT NULL, KEY idx_user_time (user_id, created_at) );

注意别用user做表名,user是MySQL保留字,建表直接报错。行为日志表是推荐系统的数据源头,索引一定要按查询方式建:推荐接口只查某个用户最近的行为,idx_user_time就够用了。如果以后要做热门统计,再给spot.heat加普通索引,不要一开始就把索引建满。

3.3 推荐接口三段式:召回、过滤、热门兜底

推荐接口的代码不复杂,但要严格分成三段:召回、过滤、兜底。下面这段是service里的核心方法,按这个顺序写,后面排查问题会轻松很多。

@Service public class RecommendService { @Autowired private StringRedisTemplate redis; @Autowired private SpotRepository spotRepo; @Autowired private BehaviorLogRepository behaviorRepo; public List<SpotVO> recommend(Long userId, int size) { // 1. 召回:取用户最近 30 天的正向行为作为种子 List<Long> seeds = behaviorRepo.findRecentFavoriteIds(userId, 30); if (seeds.isEmpty()) { return spotRepo.findByIds(spotRepo.findHotIds(size)); // 冷启动兜底 } // 2. 取每个种子的相似景点,聚合分数 Map<Long, Double> scoreMap = new HashMap<>(); Set<Long> seedSet = new HashSet<>(seeds); // 过滤时用 Set,避免 O(n²) for (Long itemId : seeds) { Set<ZSetOperations.TypedTuple<String>> tuples = redis.opsForZSet() .reverseRangeWithScores("sim:" + itemId, 0, 9); if (tuples == null) continue; for (ZSetOperations.TypedTuple<String> tuple : tuples) { Long simItemId = Long.valueOf(tuple.getValue()); if (seedSet.contains(simItemId)) continue; // 过滤已交互 scoreMap.merge(simItemId, tuple.getScore(), Double::sum); } } // 3. 按分数排序,取前 size List<Long> ids = scoreMap.entrySet().stream() .sorted(Map.Entry.<Long, Double>comparingByValue().reversed()) .limit(size) .map(Map.Entry::getKey) .collect(Collectors.toList()); // 4. 数量不足,用热门补位 int lack = size - ids.size(); if (lack > 0) { ids.addAll(spotRepo.findHotIdsExcluding(ids, lack)); } return spotRepo.findByIds(ids); } }

几个参数先说清楚:reverseRangeWithScores("sim:" + itemId, 0, 9)表示取相似度最高的10个,这是召回宽度;种子只取view/fav/order里的正向行为,SQL里要过滤掉取消收藏之类负向动作;findHotIdsExcluding是为了让热门兜底和召回结果不重复。容易踩的细节有三个:seedSet必须在循环外构建,否则每轮都new一个Set,行为一多性能就崩;scoreMap用merge累加,一个景点被多个种子命中就重复累加相似度,这是合理策略;limit一定要在排序后执行,先limit再排序会丢分。

3.4 自动装配与定时任务:相似度矩阵每天只重算一次

Spring Boot的自动装配原理让工程少写很多配置,但推荐系统里有两个配置要显式声明:一是RedisTemplate的序列化器,二是开启定时任务。RedisTemplate默认的JdkSerializationRedisSerializer会把Double存成二进制乱码,换成StringRedisSerializer后,ZSET的score和value都可读,排查线上问题容易得多。

定时任务用来加载离线算好的相似度矩阵。常见做法是每天凌晨执行一次全量加载:

@EnableScheduling @Configuration public class ScheduleConfig { @Scheduled(cron = "0 30 3 * * ?") // 每天凌晨 3:30 执行 public void reloadSimMatrix() { // 读取离线产出的 sim_matrix.csv,逐行写入 Redis // 先写临时 key,全部写完再改名,避免接口读到一半新一半旧 } }

这里有两个坑:Spring的cron表达式固定6位,和Linux的5位crontab不一样,写错启动直接报错;全量刷新期间接口还在读Redis,推荐做法是先写入临时key再原子改名,否则用户会刷到一条推荐里新旧景点混着来。这个顺序我调试了几次才固定下来,属于典型的“不加注意就翻车”的细节。

4. Vue前端:推荐页从接口到一jar打包部署

4.1 环境准备:Vue安装及依赖的几个注意点

Vue前端第一步是环境:Node版本和npm依赖。Vue项目对Node版本敏感,新版Vue 3对应的Vite要求Node 16以上,如果机器上还是老版本,npm install会装一半就报错。常见做法是用nvm管理Node版本,并在项目根目录放一个.nvmrc文件锁定版本。

依赖安装也有讲究。首次npm install会拉很多包,报错时先把node_modules和package-lock.json删掉重新装,往往比一行行查报错快。这个“删掉重装”不是玄学,是npm缓存版本和lock文件解析不一致导致的安装问题,我在这上面耗过不少时间。

4.2 推荐页组件与路由:axios请求、加载状态、动态路由

推荐页在Vue里一般拆成三块:加载状态、推荐列表、空状态。核心逻辑是把后端推荐接口的数据取回来,循环渲染景点卡片。下面这段代码是页面里的最小可跑逻辑:

<script setup> import { ref, onMounted } from 'vue' import { useRoute } from 'vue-router' import axios from 'axios' import SpotCard from '@/components/SpotCard.vue' const route = useRoute() const spots = ref([]) const loading = ref(true) const error = ref('') async function loadRecommendations() { loading.value = true try { const userId = route.query.userId || 1 const res = await axios.get(`/api/recommend/${userId}`, { timeout: 5000 }) spots.value = res.data.data } catch (e) { error.value = '推荐加载失败,请稍后重试' } finally { loading.value = false } } onMounted(loadRecommendations) </script>

这里有两个容易忽略的点。timeout必须显式给,推荐接口第一次调可能涉及Redis冷查询,超过5秒没返回用户就流失了,前端超时后显示错误提示,比无限loading好得多。userId从route.query里取,因为推荐页通常由首页跳转过来,要带上当前登录用户的ID;vue动态路由用来做景点详情页更合适,比如/spot/:id,但推荐页本身不要用动态路由,query参数多的时候会很难维护。

路由配置也简单,两个路由就够了:

const routes = [ { path: '/recommend', name: 'Recommend', component: RecommendPage }, { path: '/spot/:id', name: 'SpotDetail', component: SpotDetailPage } ]

页面上还要处理空列表:接口返回空数组时,列表区域显示“暂时没有推荐,先去热门景点逛逛”的空状态,而不是一片空白。这个空状态在冷启动场景下一定会出现,提前做了省得后面被问。

4.3 把Vue打包放进Spring Boot:一个jar跑完前后端

网上很多“基于springboot vue的项目”是前后端分离部署,实际内部小团队惯用简化部署:Vue构建成静态资源,放进Spring Boot的src/main/resources/static,打包后一个jar直接跑,省掉Nginx和跨域配置。这就是“vue打包放进springboot中”最常见的手法。

# 在前端目录执行构建 npm run build # 产物默认在 dist/ # 把 dist 下的内容复制到后端 static 目录 cp -r dist/* ../backend/src/main/resources/static/

开发环境则需要在vite.config.js里配置代理,把/api转发到localhost:8080;发布后前后端同源,不需要代理,跨域配置只会带来额外麻烦。如果用了history模式的路由,还要考虑:/spot/1这种地址后端没有对应Controller,直接刷新会404。要么回归hash模式,要么在后端加一个转发Controller,把这类前端路由全部forward到index.html。

前端源码发给别人时也常在这步出错:dist一次性打包进去后,接收方改前端代码要重新build再复制,经常出现改了页面不生效的情况。提前告诉协作的人前端和后端是两个工程,能省掉不少交接成本。

提示:dist里的js/css文件名带hash,打包一次变一次。如果对静态资源做了长缓存,用户刷新后还是旧版本,部署时记得对index.html禁用缓存。

5. 协同过滤避坑清单:冷启动、稀疏数据与Spring Boot版本兼容

5.1 冷启动:新用户的推荐页是空的

现象:新用户刚注册进来,没有任何行为日志,调用推荐接口返回空数组,前端只能显示空白页。

原因:ItemCF和UserCF都依赖用户行为,新用户没有收藏没有浏览,相似度算法算无可算。这不是代码写错,是算法的数据依赖。

解决:做两层兜底。后端在召回种子为空时直接返回热门景点列表;前端在列表为空时展示带城市筛选的热门榜。热门排序用spot.heat字段,再叠加“周边城市优先”这类业务规则。冷启动推荐要单独埋点看后续转化率,等用户攒够3条以上行为再切换成协同过滤结果。

5.2 稀疏数据:相似度矩阵大量为0

现象:离线算相似度时发现矩阵里非零项不到1%,线上推荐结果退化成了热门榜,看起来和没做算法一样。

原因:旅游用户行为极稀疏,很多景点只有几个人评分。两个景点如果没有共同评分用户,余弦相似度就是0,景点数量越多这个问题越严重。

解决:过滤低频物品和低频用户,只保留被至少5个用户评分过的景点,也过滤行为少于3条的用户,把矩阵缩到有效范围内再算相似度。相似度低于0.1的边直接丢弃,避免大量弱连接变成推荐噪音。如果稀疏还是很严重,下一步就该上SVD矩阵分解了,但那是第二个版本的事,第一版先让规则跑通。

5.3 新景点没有行为数据被算法“歧视”

现象:景区上线一周,后台数据同步正常,但推荐列表里永远看不到它。

原因:协同过滤只认行为,不认景点内容。新景点评分矩阵里全0,和任何景点相似度都是0,算法天然不会推它。

解决:给新景点一个“内容冷启动期”。用city和tags字段算内容相似度,比如西湖和曲院风荷都在杭州,标签都是“湖泊、5A、免费”,内容相似度就高。在新景点行为数据达到阈值之前,用内容相似度替代协同过滤,积累足量行为之后再切回ItemCF。阈值放在配置中心里维护,运营想推新景区就调门槛,不用改代码。

5.4 Spring Boot版本“太新”引发的连锁问题

现象:本地开发一切正常,换同事机器拉代码编译失败;或者控制台报ClassNotFound,类名在javax包下面,但当前Spring Boot版本只认jakarta。

原因:Spring Boot从3.x开始把javax迁移到jakarta命名空间,老项目里的javax.servlet、javax.annotation要全部改包名;“springboot版本太高”还会把一堆传递依赖一起升到新版,比如Redis客户端、Jackson版本,都可能产生行为差异。

解决:团队先定版本基线,不要拿新版本去试老代码。看到网上教程是新写法、本地是老项目时,优先用maven dependency:tree看实际拉到的版本,再决定改代码还是锁版本。顺序一般是:先改pom里的版本号,看依赖树有没有冲突,最后才改代码。这个顺序能省半天排查时间。

6. 上线前先做一轮离线评估:三个参数最值得调

推荐系统上线后,先别急着看用户点不点,先做离线评估。常见做法是把行为日志按时间切成两段:前80%做训练,后20%做测试,看测试集里的景点有多少被推荐命中。

# train和test是两个DataFrame,train用于计算相似度,test用于验证 # get_recommend(train, uid, top=10) 走第2章的逻辑 hit = 0 for uid, row in test.iterrows(): rec = get_recommend(train, uid, top=10) if row["item_id"] in rec: hit += 1 print("top10命中率:", hit / len(test))

top的取值一般10或20,太小看不出推荐多样性,太大用户根本不会往下翻。命中率之外,还要看“新发现景点数”,也就是测试集里有多少景点是被推荐命中但用户没主动搜过的,这个数比命中率更能说明算法的信息增量。

参数默认值参考调大影响调小影响调参建议
种子召回K每个种子取10个相似覆盖广但精度下降推荐更准但容易空矩阵稀疏就调大
行为权重差view/fav/order=1/3/5收藏信号更强浏览主导容易泛order要明显高于fav
热门兜底数size的20%列表稳定但个性化弱新人没有可看的冷启动期调大

这三个参数里,行为权重差最容易出效果:收藏3分和下单5分的差距,决定了付费意向权重有多大;如果系统里下单数据本身少,把order权重调到8,推荐会很快偏向高转化景点。调完参数要重新跑离线评估,不要凭感觉上线。

我自己习惯每周把推荐日志拉出来,看两个数:推荐列表里新景点占比和相似度平均分数。新景点占比低于10%,就说明算法被热门吃掉了;相似度平均分数突然掉一半,多半是相似度矩阵过期了。新物品冷启动、相似度阈值、时间衰减系数这些参数,每两周调一次就够了,调太频繁反而会把系统搞得不稳定。这个项目方向很适合起步:后端逻辑不复杂,算法效果看得见,踩坑也有规律。希望帮到你。

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

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

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

立即咨询