ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于SpringBoot+Vue的协同过滤旅游推荐系统搭建与避坑指南

2026/10/7 22:37:35 拓冰建站 浏览量
基于SpringBoot+Vue的协同过滤旅游推荐系统搭建与避坑指南 简介这是一份基于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-基于springbootvue的协同过滤算法旅游推荐系统.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_indexFalse)[score].sum() matrix agg.pivot_table(indexuser_id, columnsitem_id, valuesscore).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 ZSETkey命名成sim:{itemId}value是相似景点ID和相似度分数。离线算好后导出CSVJava端用定时任务读取并写入Redis。文件里只保留相似度大于0.05的边不然全量N乘N矩阵会膨胀得很厉害5000个景点的全量矩阵是2500万行剪枝后可能只剩几十万行写入速度和查询效率都完全跟得上。注意相似度剪枝阈值不要设太高0.05级别比较稳。阈值太高会让冷门景点彻底失去相似邻居推荐列表会更早退化成热门榜。3. Spring Boot后端推荐接口三段式与Redis缓存策略3.1 解压后的工程结构前端后端分目录接口和算法分层这种zip包解压后目录几乎都是同一套布局backend放Spring Bootfrontend放Vue少数把dist打包进static后合并成单工程。一个“基于springboot vue的项目”典型结构是这样的目录/文件职责backend/controller接收参数、封装统一返回backend/service推荐逻辑、兜底逻辑backend/repository数据访问MyBatis-Plus Mapper或JPAbackend/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 ListSpotVO recommend(Long userId, int size) { // 1. 召回取用户最近 30 天的正向行为作为种子 ListLong seeds behaviorRepo.findRecentFavoriteIds(userId, 30); if (seeds.isEmpty()) { return spotRepo.findByIds(spotRepo.findHotIds(size)); // 冷启动兜底 } // 2. 取每个种子的相似景点聚合分数 MapLong, Double scoreMap new HashMap(); SetLong seedSet new HashSet(seeds); // 过滤时用 Set避免 O(n²) for (Long itemId : seeds) { SetZSetOperations.TypedTupleString tuples redis.opsForZSet() .reverseRangeWithScores(sim: itemId, 0, 9); if (tuples null) continue; for (ZSetOperations.TypedTupleString tuple : tuples) { Long simItemId Long.valueOf(tuple.getValue()); if (seedSet.contains(simItemId)) continue; // 过滤已交互 scoreMap.merge(simItemId, tuple.getScore(), Double::sum); } } // 3. 按分数排序取前 size ListLong ids scoreMap.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().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里取因为推荐页通常由首页跳转过来要带上当前登录用户的IDvue动态路由用来做景点详情页更合适比如/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是两个DataFrametrain用于计算相似度test用于验证 # get_recommend(train, uid, top10) 走第2章的逻辑 hit 0 for uid, row in test.iterrows(): rec get_recommend(train, uid, top10) if row[item_id] in rec: hit 1 print(top10命中率:, hit / len(test))top的取值一般10或20太小看不出推荐多样性太大用户根本不会往下翻。命中率之外还要看“新发现景点数”也就是测试集里有多少景点是被推荐命中但用户没主动搜过的这个数比命中率更能说明算法的信息增量。参数默认值参考调大影响调小影响调参建议种子召回K每个种子取10个相似覆盖广但精度下降推荐更准但容易空矩阵稀疏就调大行为权重差view/fav/order1/3/5收藏信号更强浏览主导容易泛order要明显高于fav热门兜底数size的20%列表稳定但个性化弱新人没有可看的冷启动期调大这三个参数里行为权重差最容易出效果收藏3分和下单5分的差距决定了付费意向权重有多大如果系统里下单数据本身少把order权重调到8推荐会很快偏向高转化景点。调完参数要重新跑离线评估不要凭感觉上线。我自己习惯每周把推荐日志拉出来看两个数推荐列表里新景点占比和相似度平均分数。新景点占比低于10%就说明算法被热门吃掉了相似度平均分数突然掉一半多半是相似度矩阵过期了。新物品冷启动、相似度阈值、时间衰减系数这些参数每两周调一次就够了调太频繁反而会把系统搞得不稳定。这个项目方向很适合起步后端逻辑不复杂算法效果看得见踩坑也有规律。希望帮到你。本文还有配套的精品资源点击获取