ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于SpringBoot+Vue的协同过滤旅游推荐系统实战:从算法到前后端联调

基于SpringBoot+Vue的协同过滤旅游推荐系统实战:从算法到前后端联调 简介这是一套基于SpringBoot与Vue的协同过滤算法旅游推荐系统完整源码包面向计算机相关专业的毕业设计、课程设计及大作业需求者也适合希望学习前后端分离架构与推荐算法落地的进阶开发者。项目采用Java语言与SpringBoot框架搭建后端服务前端使用Vue.js实现用户界面数据库为MySQL 5.7开发环境兼容Eclipse、MyEclipse与IDEAMaven版本为3.3.9JDK要求1.8。压缩包共341个文件约11.3MB其中包含89个Java源文件、68个Vue组件、40个JavaScript脚本、19个CSS样式表及14个XML配置另有SQL建库脚本、项目说明文档与图片资源结构完整便于二次开发。目前已有842人学习下载。读者可借此掌握协同过滤推荐算法在旅游场景中的实现思路理解前后端分离项目的目录组织与接口调用方式并可直接运行调试作为毕设或实训项目的参考基础。1. 从一份毕设代码包说起协同过滤旅游推荐到底在解决什么问题打开一份名为「基于springbootvue的协同过滤算法旅游推荐系统」的代码包很多人第一反应是翻 README 找启动命令结果发现前后端分离、数据库脚本、算法模块散落在不同目录跑不起来也说不清推荐结果从哪来。这个标题背后其实是一条完整的工程链路SpringBoot 提供后端接口与业务逻辑Vue 负责前端交互与页面渲染协同过滤算法负责从用户行为数据里算出「你可能还想去哪」。它解决的核心问题是——当旅游平台上的景点数量远超用户能浏览的范围时如何用历史评分或收藏行为自动给每个人推一份个性化的目的地清单。这套方案适合三类人正在做毕设、需要一套能讲清算法原理又能演示前后端联调的学生想从零搭一个推荐模块、但不想直接上深度学习的中小团队以及已经会用 SpringBoot 和 Vue但没接触过推荐算法的开发者。它不追求工业级推荐系统的召回率而是用最小可运行的方式把「数据采集 → 相似度计算 → 推荐生成 → 前端展示」这条链路走通。下面按实际落地顺序拆开讲每一步都给出可复现的配置和代码。2. 协同过滤选型与数据建模为什么不用深度学习也能跑出推荐2.1 用户-based 与物品-based 的取舍逻辑协同过滤分两条路UserCF 找「和你口味相似的人」ItemCF 找「和你喜欢的景点相似的景点」。旅游场景里用户评分数据通常稀疏——一个人可能只去过三五个城市但景点数量动辄上千。UserCF 在用户数远大于物品数时效果更好但旅游平台往往反过来景点数量可控用户行为分散。所以常见做法是 ItemCF 为主、UserCF 做补充。ItemCF 的核心是计算物品之间的相似度矩阵当用户对某个景点产生行为时推荐与之最相似的 TopN 景点。相似度计算用余弦相似度或皮尔逊相关系数。余弦相似度对评分尺度不敏感适合旅游场景里有人打 5 分有人打 3 分的习惯差异。公式是两个景点被同一批用户评分的向量夹角余弦值。实际写代码时先构建「用户-物品」评分矩阵再转置成「物品-用户」矩阵逐对计算相似度。数据量小时可以直接用双重循环数据量上千后必须上稀疏矩阵或倒排索引否则启动时算相似度能卡到超时。2.2 数据库表结构与评分矩阵构建旅游推荐系统的数据层至少需要四张表用户表、景点表、评分/行为表、推荐结果缓存表。评分表是算法输入的核心字段包括 user_id、item_id、rating、timestamp。rating 可以是 1-5 的显式评分也可以是收藏、浏览、下单等隐式行为的加权值。时间戳用于做时间衰减——半年前的行为权重应该低于上周的行为。CREATE TABLE user_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, item_id BIGINT NOT NULL, rating DECIMAL(3,1) DEFAULT 0, behavior_type TINYINT COMMENT 1浏览 2收藏 3评分 4下单, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_item (item_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表时注意两点rating 用 DECIMAL 而不是 FLOAT避免浮点误差在相似度累加时放大behavior_type 单独存一列方便后续做加权。索引必须加在 user_id 和 item_id 上否则几千条数据后查询行为列表就会全表扫描。评分矩阵在 Java 侧用MapLong, MapLong, Double承载外层 key 是 itemId内层是 userId 到评分的映射。内存不够时换用Fastutil的Long2ObjectOpenHashMap比 JDK 原生 Map 省一半以上内存。2.3 SpringBoot 中算法模块的目录划分不要把协同过滤代码塞进 Controller 或 Service 里。常见做法是单独建一个recommend包下面分model评分矩阵、相似度矩阵、similarity余弦、皮尔逊实现、service推荐生成、结果缓存。Controller 只负责接收请求和返回 DTO算法逻辑全部在 service 层。这样做的直接好处是换相似度算法时只改 similarity 包前端接口完全不用动。Service public class ItemCFRecommendService { private final MapLong, MapLong, Double itemUserMatrix new HashMap(); private final MapLong, ListItemSimilarity similarityCache new HashMap(); // 从数据库加载行为数据构建物品-用户矩阵 public void buildMatrix(ListUserBehavior behaviors) { for (UserBehavior b : behaviors) { itemUserMatrix .computeIfAbsent(b.getItemId(), k - new HashMap()) .merge(b.getUserId(), b.getRating().doubleValue(), Double::sum); } } // 计算物品相似度并缓存 public void computeSimilarity() { ListLong itemIds new ArrayList(itemUserMatrix.keySet()); for (int i 0; i itemIds.size(); i) { for (int j i 1; j itemIds.size(); j) { double sim cosine(itemUserMatrix.get(itemIds.get(i)), itemUserMatrix.get(itemIds.get(j))); if (sim 0.1) { // 阈值过滤避免存大量弱相似 similarityCache.computeIfAbsent(itemIds.get(i), k - new ArrayList()) .add(new ItemSimilarity(itemIds.get(j), sim)); similarityCache.computeIfAbsent(itemIds.get(j), k - new ArrayList()) .add(new ItemSimilarity(itemIds.get(i), sim)); } } } } }这段代码的关键参数是相似度阈值 0.1。低于这个值的物品对即使存下来推荐时也排不进 TopN反而浪费内存。实际调参时可以先打印相似度分布看多少比例落在 0.1 以下再决定阈值。另一个注意点是merge里的Double::sum——如果同一用户对同一景点有多次行为这里会累加评分相当于隐式加权。如果只想取最新一次评分改成(oldVal, newVal) - newVal。3. 前后端联调从推荐接口到 Vue 页面的完整链路3.1 推荐接口的入参与返回结构设计后端推荐接口一般设计成GET /api/recommend/{userId}?size10。userId 从登录态里取size 控制返回条数默认 10。返回结构不要直接扔景点实体而是包一层推荐理由——比如「因为您收藏了 XX 景点」。这样前端展示时更有说服力也方便排查推荐结果是否符合预期。GetMapping(/api/recommend/{userId}) public ResultListRecommendItemVO recommend( PathVariable Long userId, RequestParam(defaultValue 10) int size) { ListRecommendItemVO list recommendService.recommend(userId, size); return Result.success(list); }RecommendItemVO里至少包含 itemId、itemName、score、reason 四个字段。score 是推荐分数用于前端排序或调试reason 是推荐理由字符串由算法层在生成推荐时一并写入。注意 size 要做上限校验比如最大 50防止前端传个 1000 把数据库拖垮。3.2 Vue 侧调用与推荐列表渲染Vue 项目里用 axios 调推荐接口在created或mounted钩子里发起请求。推荐列表通常放在首页或「猜你喜欢」模块用v-for渲染。关键点是加载状态和空结果的处理——推荐算法冷启动时可能返回空列表前端要显示「暂无推荐先去逛逛吧」而不是白屏。async fetchRecommend() { this.loading true; try { const res await axios.get(/api/recommend/${this.userId}, { params: { size: 8 } }); this.recommendList res.data.data || []; } catch (e) { console.error(推荐接口异常, e); this.recommendList []; } finally { this.loading false; } }axios 的params会自动拼成查询字符串不用手动拼接。res.data.data这种双层结构是因为后端统一返回了Result包装类第一层 data 是 axios 的响应体第二层才是业务数据。如果后端没做统一包装这里直接取res.data即可。踩坑最多的地方是跨域——开发阶段 Vue 跑在 8080SpringBoot 跑在 8081需要在 SpringBoot 侧加CrossOrigin或在 Vue 的vue.config.js里配 proxy。3.3 用 Vue Devtools 排查推荐数据不渲染的问题推荐列表不显示时先打开 Vue Devtools 看组件树里recommendList的值。如果是空数组问题在后端接口如果有数据但页面没渲染问题在模板绑定。常见原因是字段名对不上——后端返回itemName前端写item.name。另一个高频问题是异步时序在created里调接口但模板里用了v-ifrecommendList.length数据回来前组件已经渲染过一次空状态数据回来后没触发更新。解决办法是用v-ifloading控制加载态加载完成后再渲染列表。4. 避坑与排查协同过滤旅游推荐系统最常见的五个翻车点4.1 相似度矩阵启动时计算超时现象SpringBoot 启动后第一次调推荐接口等十几秒才返回甚至直接 504。原因在接口请求时才构建评分矩阵和相似度矩阵数据量几千条时双重循环计算量是 O(n²)。解决把矩阵构建和相似度计算放到PostConstruct或定时任务里启动时算一次并缓存。数据更新不频繁的话每天凌晨重算一次即可。如果数据量大到内存放不下改用 Redis 存相似度结果按 itemId 做 key。4.2 冷启动用户拿不到推荐现象新注册用户没有任何行为数据推荐接口返回空列表。原因协同过滤依赖历史行为新用户不在评分矩阵里。解决做兜底策略——新用户返回热门景点 TopN 或按地域推荐。热门榜可以按最近 7 天的行为数排序地域推荐用用户注册时填的城市匹配景点所在城市。等用户产生 3 条以上行为后再切回协同过滤结果。4.3 评分数据稀疏导致推荐全是同几个景点现象不管哪个用户推荐结果里反复出现同样的三五个景点。原因这些景点被评分次数最多在相似度计算时与所有物品都有较高相似度形成「热门偏见」。解决在推荐分数里除以物品的流行度惩罚项公式是score / Math.log(1 itemBehaviorCount)。这样热门景点的基础分被拉低长尾景点有机会冒出来。惩罚系数需要根据实际数据调一般从 0.5 开始试。4.4 前端推荐列表点击后跳转 404现象推荐卡片能显示点击后路由跳转失败。原因推荐接口返回的 itemId 对应的详情页路由没配或者路由参数名不一致。解决检查 Vue Router 里详情页的 path 定义比如/spot/:id跳转时用this.$router.push(/spot/ item.itemId)。如果详情页需要额外参数如来源渠道在推荐 VO 里一并返回不要在前端拼。4.5 数据库行为表数据量涨太快现象上线一个月后行为表几百万行推荐接口查询变慢。原因每次浏览都插一条记录没有做归档或聚合。解决行为表按天分区或者每天定时把原始行为聚合成「用户-物品-总评分」的汇总表推荐算法只读汇总表。原始表保留 30 天用于回溯超期数据归档到冷存储。聚合任务用 Spring 的Scheduled就能做不需要引入额外调度框架。5. 让推荐结果可解释一个提升演示效果的具体技巧推荐系统在毕设答辩或内部演示时最容易被问的问题是「为什么给我推这个」。协同过滤本身只输出分数不输出理由。但可以在生成推荐时反向查一步对于推荐给用户 A 的景点 X找到与 X 最相似的、且 A 已经有过行为的景点 Y把 Y 的名字填进 reason 字段。这样前端展示时就能写「因为您收藏了 Y所以推荐 X」。实现上只需要在recommend方法里多查一次相似度缓存。private String buildReason(Long userId, Long itemId) { ListItemSimilarity sims similarityCache.get(itemId); if (sims null || sims.isEmpty()) return 热门推荐; // 找到该用户有过行为的、且与当前物品最相似的那个 for (ItemSimilarity sim : sims) { if (userBehaviorService.hasBehavior(userId, sim.getItemId())) { String name itemService.getName(sim.getItemId()); return 因为您喜欢「 name 」; } } return 根据相似用户推荐; }这段代码的hasBehavior查询要加缓存否则每个推荐项都查一次数据库10 条推荐就是 10 次查询。简单做法是用SetLong把用户行为过的 itemId 一次性加载到内存判断时直接contains。另外相似度列表要按相似度降序排列这样找到的第一个有行为的物品就是最相关的理由。如果用户行为数据很少可能找不到任何匹配兜底返回「根据相似用户推荐」即可。调参时注意reason 的生成会增加推荐接口的耗时如果相似度列表很长比如每个物品存了 500 个相似物品遍历成本不低。可以在构建相似度缓存时就截断每个物品只保留 Top50 相似物品既省内存又加快理由生成。这个截断值我一般设 50实测对推荐准确率影响很小但接口响应能快一倍。最后说一个习惯每次改完相似度阈值或推荐条数不要只看接口返回的 JSON一定打开 Vue 页面看实际渲染效果。JSON 里 score 排第一的景点在页面上可能因为图片加载失败或标题过长而显得很奇怪。推荐系统的效果最终是给人看的数据对不代表体验对。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进