拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

协同过滤算法在个性化旅游推荐系统中的Java+Vue全栈实现

协同过滤算法在个性化旅游推荐系统中的Java+Vue全栈实现 大概每个做毕业设计或课程设计的人都经历过这个阶段在题目清单里来回划拉软件类清一色的XX管理系统算法类又怕公式推导太硬核。我当时挑了一圈最后落在基于协同过滤算法的个性化旅游推荐平台这个题目上原因很简单——它既有完整的业务系统Java Vue 是很成熟的组合又在核心模块里塞了一个真正有算法含量的推荐引擎。无论是做课设还是毕设都能把工作量和技术亮点同时讲清楚。这个项目能做什么一句话概括用户登录后可以看到猜你喜欢的景点推荐点进景点详情页还有相似景点推荐。推荐结果不是简单的热度排序而是基于当前用户和历史用户的行为数据评分、收藏、浏览记录算出来的。系统后台可以维护景点信息、查看用户行为日志。对想学 Java 后端 Vue 前端全栈开发、又想让项目里有一点算法实体的同学来说这是一个很好的复现样本。1. 为什么选这个课题旅游推荐系统比你想的更吃协同过滤市面上常见的毕设选题跑不出图书管理系统超市管理系统新闻发布系统这几个大类。这些系统的通病是业务逻辑就是增删改查数据库 CRUD 写一百遍也还是 CRUD答辩时老师问一句你的项目有什么技术难点场面容易冷住。旅游推荐平台这个题目不一样它天然需要一个推荐引擎而推荐引擎的核心算法就是协同过滤。1.1 旅游场景的特殊性决定了算法必须个性化先想一个事旅游决策和买书、看电影有什么本质区别买书可以一天买好几本看电影一个月看十几部但旅游是典型的低频高决策成本行为——一个人一年可能就出去一两次每次要花几千块还会花大量时间查攻略。这种场景下用户对推荐的容忍度很低给他推一个不合适的景点他可能直接关掉页面不会再给你第二次机会。同时旅游偏好又是高度个性化的。有人喜欢自然风光有人喜欢历史人文有人专找小众打卡点有人只去亲子乐园。单纯按浏览量最高或评分最高排序推荐结果会很平庸甚至对部分用户完全无效。这时候协同过滤的价值就体现出来了它不依赖人工打标签而是通过和你行为相似的人喜欢什么来推测你可能喜欢什么在旅游这种偏好分化明显的场景里准确率通常远高于热门排序。1.2 项目中协同过滤的两种落地形态整个平台里协同过滤算法出现在两个位置首页的猜你喜欢推荐列表根据当前用户的评分和收藏行为找到与他最相似的一群用户把那些用户喜欢但当前用户还没看过的景点推出来。这叫基于用户的协同过滤UserCF。景点详情页的相似景点推荐根据所有用户对景点的评分向量计算景点之间的相似度在当前景点旁展示最相近的几个景点。这叫基于物品的协同过滤ItemCF。两种算法我在后面会展开讲这里先记住结论页面上的每一个推荐结果都不是后端随手查出来的而是经过相似度计算、排序、过滤之后生成的。这一个点讲清楚项目含金量立刻不一样。1.3 项目功能全景整个系统拆开来大概覆盖这些功能模块用户端注册登录、浏览景点、查看景点详情、对景点评分、收藏景点、查看个人推荐列表。推荐引擎实时计算用户相似度或物品相似度生成 Top-N 推荐列表处理冷启动场景。管理端景点信息维护名称、地区、级别、票价、介绍、图片、用户管理、评论审核。如果你是在做课程设计可以砍掉管理端的一部分功能如果做毕设建议保留完整链路答辩时可讲的内容会多很多。2. 技术选型与整体架构Spring Boot Vue 是怎么搭起来的技术栈选择上我没有整花活儿。后端用 Java Spring Boot前端用 Vue数据库用 MySQLORM 用 MyBatis-Plus。这套组合可能被说老套但作为课程设计和毕业设计它有一个巨大的优势生态资料极多遇到问题几乎都能搜到现成解决方案。2.1 后端Spring Boot 3 MyBatis-Plus 的搭配逻辑Spring Boot 选它原因很直接内嵌 Tomcat不用单独配置服务器一个mvn spring-boot:run就能起服务。配合 MyBatis-Plus单表 CRUD 基本不用写 SQLBaseMapper里现成的方法够用。对推荐系统这种需要大量操作数据库、又不想花太多时间在持久层上的项目来说MP 能省下不少时间。我用的版本是 Spring Boot 3.2.x MyBatis-Plus 3.5.x。注意一个坑Spring Boot 3 是 Jakarta 命名空间网上很多老的 MyBatis-Plus 教程是基于 javax 的直接抄配置会报包找不到的错。如果你不熟悉这些差异稳妥起见直接用 Spring Boot 2.7.x MyBatis-Plus 3.5.x教程多坑少功能上百完全够用。2.2 前端Vue 3 Element Plus Axios前端用 Vue 3 的组合式 APIComposition API写的。组件库选 Element Plus不是因为它的组件多高级而是因为表格、表单、分页这些后台管理页面里高频出现的东西它开箱即用能少写大量 CSS。前后端交互用 Axios。我在src/utils/request.js里封装了一个统一的请求实例配置了baseURL和拦截器import axios from axios const request axios.create({ baseURL: http://localhost:8080/api, timeout: 10000 }) // 响应拦截器统一处理返回结构 request.interceptors.response.use( (response) { const res response.data if (res.code ! 200) { return Promise.reject(new Error(res.message || 请求失败)) } return res }, (error) { return Promise.reject(error) } ) export default request后端统一返回{ code: 200, data: ..., message: success }的结构。这样前端拦截器拿到res后业务代码里只需要关心data字段不用每个页面都做错误处理代码会干净很多。2.3 为什么不用更复杂的技术栈有人可能会问推荐系统难道不应该用 Python 写吗或者至少上 Redis 做缓存我的答案是要看场景。这个项目的数据量级大概率是几千条景点数据、几十个用户、几万条评分记录。这种体量下纯 Java 内存计算完全扛得住单次推荐计算不超过几百毫秒。引入 Python 服务意味着要多维护一套跨语言接口引入 Redis 意味着部署时多一个依赖。对于课程设计和毕业设计来说复杂度越高风险越大。先把核心算法用最简单的方式跑通再去谈优化这个节奏才对。整体架构就是标准的前后端分离前端 Vue 开发服务器或 Nginx 托管静态文件通过 HTTP 请求访问后端 Spring Boot 的 REST API后端连接 MySQL 数据库。没有消息队列没有微服务没有分布式缓存——这些词在答辩时可以提但作为优化方向提比真做进去稳妥得多。3. 协同过滤算法落地评分矩阵构建与相似度计算全流程很多人写协同过滤的毕设代码是抄的原理是背的答辩时老师一问你的 Jaccard 相似度和余弦相似度有什么区别就卡住。这一章我会把算法从公式到实现完整拆开原则是看懂之后你能自己手写一遍。3.1 评分矩阵从哪里来不是只有打分才算行为协同过滤的第一步是把用户的行为转化为一个用户-物品评分矩阵。矩阵的行是用户列是物品景点值是用户对该物品的反馈程度。这个系统里评分数据的来源有三类显式评分用户进入景点详情页点击星星给景点打 1~5 分。这是最直接的反馈也是矩阵的主力数据。收藏行为用户点击收藏按钮说明他对这个景点有明显的正向偏好。浏览行为用户点击了景点详情页算一个轻量级反馈。这三类行为量纲不同不能直接相加。我在代码里做了一个简单的加权映射public class RatingSource { public static final double SCORE_WEIGHT 1.0; // 显式评分 public static final double FAVORITE_WEIGHT 0.8; // 收藏 public static final double BROWSE_WEIGHT 0.3; // 浏览 }最终矩阵里某个用户对某景点的评分值是三类行为加权求和后的结果。这样做的好处是即使某个用户从未打过分只要他有过浏览和收藏行为矩阵里依然有他的数据行算法就可以参与计算不至于一上来就全是零。3.2 余弦相似度两句话讲清楚的数学公式协同过滤要解决的核心问题是如何判断两个用户像不像最常用的方法是余弦相似度。把每个用户的评分向量想象成高维空间里的一个点两个用户越像他们向量之间的夹角就越小余弦值越接近 1。公式长这样similarity(u, v) sum(u_i * v_i) / (sqrt(sum(u_i^2)) * sqrt(sum(v_i^2)))其中u_i是用户 u 对第 i 个物品的评分v_i是用户 v 对第 i 个物品的评分。举一个具体的例子。假设有三个景点 A、B、C用户甲对它们的评分是 [5, 3, 0]0 表示没看过用户乙是 [4, 3, 0]用户丙是 [2, 0, 5]。计算甲乙相似度分子5*4 3*3 0*0 20 9 29 分母sqrt(2590) * sqrt(1690) sqrt(34) * sqrt(25) ≈ 5.83 * 5 29.15 相似度29 / 29.15 ≈ 0.995而甲和丙的相似度分子5*2 3*0 0*5 10 分母sqrt(34) * sqrt(425) 5.83 * 5.39 ≈ 31.42 相似度10 / 31.42 ≈ 0.318结论很清楚甲和乙的偏好几乎一致甲和丙差异很大。这就是算法判断谁和谁像的全部逻辑没有任何神秘之处。3.3 基于用户的协同过滤UserCF推荐逻辑的完整流程用户相似度算出来之后推荐流程分四步找出与当前用户相似度最高的 K 个用户K 一般取 5~10。收集这些用户有过正反馈评分 3 或收藏过的景点。对每个景点按相似用户对该景点的评分加权求和算出当前用户对它的预测评分。过滤掉当前用户已经看过的景点按预测评分排序取 Top-N。核心的 Java 实现思路如下public ListScenic recommendForUser(Long userId, int topN) { // 1. 构建评分矩阵MapLong, MapLong, Double MapLong, MapLong, Double ratingMatrix buildRatingMatrix(); // 2. 计算当前用户与其他所有用户的余弦相似度 MapLong, Double simMap new HashMap(); MapLong, Double userVector ratingMatrix.get(userId); for (Long otherUserId : ratingMatrix.keySet()) { if (otherUserId.equals(userId)) continue; double sim cosineSimilarity(userVector, ratingMatrix.get(otherUserId)); simMap.put(otherUserId, sim); } // 3. 取 Top-K 相似用户 ListMap.EntryLong, Double topK simMap.entrySet().stream() .sorted((e1, e2) - Double.compare(e2.getValue(), e1.getValue())) .limit(10) .collect(Collectors.toList()); // 4. 加权汇总候选景点评分 MapLong, Double scoreMap new HashMap(); MapLong, Double weightSum new HashMap(); for (Map.EntryLong, Double entry : topK) { Long otherUserId entry.getKey(); double sim entry.getValue(); MapLong, Double otherVector ratingMatrix.get(otherUserId); for (Map.EntryLong, Double scenicEntry : otherVector.entrySet()) { Long scenicId scenicEntry.getKey(); double rating scenicEntry.getValue(); if (rating 3) continue; // 只考虑正反馈 if (userVector.containsKey(scenicId)) continue; // 过滤已看过的 scoreMap.merge(scenicId, sim * rating, Double::sum); weightSum.merge(scenicId, sim, Double::sum); } } // 5. 归一化后排序取 Top-N return scoreMap.entrySet().stream() .map(e - { Scenic s new Scenic(); s.setId(e.getKey()); double score e.getValue() / weightSum.getOrDefault(e.getKey(), 1.0); s.setPredictScore(score); return s; }) .sorted(Comparator.comparingDouble(Scenic::getPredictScore).reversed()) .limit(topN) .collect(Collectors.toList()); }注意第四步里的归一化每个候选景点的加权分要除以相似度之和避免某个相似用户打分特别高导致景点分数虚高的情况。3.4 基于物品的协同过滤ItemCF详情页相似景点的实现UserCF 负责猜你喜欢ItemCF 负责看了这个还看什么。两种算法的差别只有一个维度把用户-物品矩阵转置按物品的评分向量算相似度。具体说景点 A 的评分向量是所有用户对 A 的打分组成的列景点 B 同理。两个向量算余弦相似度得到A 与 B 的相似度。详情页展示的相似景点推荐就是当前景点的相似度 Top-5。代码上ItemCF 和 UserCF 的相似度计算函数是同一个只是传参时把矩阵转置一下public ListScenic recommendSimilarItems(Long scenicId, int topN) { MapLong, MapLong, Double itemRatingMatrix buildItemRatingMatrix(); // ...与 UserCF 同样的相似度计算只是向量的维度从用户变成了景点 }3.5 冷启动问题新用户和新景点怎么办协同过滤有个天生缺陷如果用户没有任何行为数据他和所有人的相似度都是 0推荐列表就是空的。这个在系统刚上线或者新用户注册时几乎必然出现。我的处理方案是一套简单的兜底策略新用户无任何行为按景点热度推荐热度 平均评分 * 0.7 收藏次数 * 0.3。行为很少少于 3 条先用热度推荐填充列表同时后台开始记录行为等数据攒够了再切换为协同过滤结果。新景点无评分进入推荐候选池但排序权重会低一些靠随机曝光或地区筛选触发首次评分。冷启动问题不需要做得太复杂能在答辩时讲清楚我知道这个问题存在并且有应对方案就已经超过大多数人了。4. 数据库设计与完整推荐链路五张核心表到你看见的猜你喜欢算法讲得再天花乱坠最后都要落到数据库上。推荐系统的数据模型和普通管理系统没有本质区别但有几张表的设计会直接影响算法的实现复杂度。4.1 核心表结构设计我的数据库叫travel_recommend核心表五张用户表、景点表、评论表、收藏表、管理员表。用户表sys_user最简单就是账号密码加昵称CREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL, nickname varchar(50) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;景点表scenic字段多一点基本信息加描述信息CREATE TABLE scenic ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, region varchar(50) DEFAULT NULL COMMENT 所属地区, level varchar(20) DEFAULT NULL COMMENT 景区级别5A/4A等, ticket_price decimal(10,2) DEFAULT NULL, description text COMMENT 景点介绍, cover_image varchar(255) DEFAULT NULL COMMENT 封面图URL, view_count int(11) DEFAULT 0 COMMENT 浏览人数, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;评论表comment是整个推荐系统最关键的一张表因为它承载了显式评分数据。每个用户对每个景点的评分都记录在这里CREATE TABLE comment ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, scenic_id bigint(20) NOT NULL, score int(11) NOT NULL COMMENT 评分1-5, content varchar(500) DEFAULT NULL COMMENT 评论内容, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_scenic (user_id, scenic_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;这里有一个很重要的设计细节联合索引idx_user_scenic。系统要构建评分矩阵时最频繁的操作就是查某个用户评过哪些景点和查某个景点被哪些用户评过分。加了联合索引这两个查询都能走索引不会全表扫描。算法部分的数据量虽然小但这个索引能让你在数据量稍微增长时依然保持流畅。收藏表favorite结构更简单用户 ID 景点 ID 收藏时间同样加联合索引。它作为辅助评分来源权重在算法里是 0.8。4.2 推荐链路的完整调用过程一次完整的推荐请求从前端到后端大致是这样流动的前端路由跳转到/recommend页面onMounted钩子里调用request.get(/recommend/personal)。后端的RecommendController接收请求从SecurityUtils或 Token 里解析出当前用户 ID。RecommendService先检查当前用户行为数量如果行为数 3走热度推荐否则调用UserCFRecommender执行协同过滤算法。算法模块从数据库加载评分数据构建矩阵计算相似度生成 Top-N 景点 ID 列表。回填景点完整信息名称、图片、票价、评分封装为统一的ListScenicVO返回。前端拿到数据渲染为卡片列表。后端 Controller 的代码非常薄核心逻辑都在 Service 层RestController RequestMapping(/api/recommend) public class RecommendController { Autowired private RecommendService recommendService; GetMapping(/personal) public ResultListScenicVO personalRecommend(RequestParam(defaultValue 10) int topN) { Long userId UserContext.getUserId(); ListScenicVO list recommendService.recommendForUser(userId, topN); return Result.success(list); } GetMapping(/similar/{scenicId}) public ResultListScenicVO similarScenic(PathVariable Long scenicId) { return Result.success(recommendService.recommendSimilarItems(scenicId, 5)); } }4.3 前端猜你喜欢页面的渲染逻辑前端页面用 Vue 的v-for渲染推荐卡片每张卡片展示景点封面、名称、地区、预测评分。这里有一个小细节推荐的预测分数和景点的原有评分是两回事前端要分开展示。预测分数是算法觉得你有多喜欢这个景点景点评分是所有用户的平均评价两者混在一起会让用户困惑。我在卡片上用的标签是为你推荐指数 XX用 Element Plus 的el-rate组件显示只读不可点。实际效果是用户能看到推荐列表每张卡片都有一个指数数值越高代表算法越确信你会喜欢。这个设计在演示时很加分因为评委一眼就能看出推荐结果是算出来的而不是查出来的。5. 实测踩坑与调优稀疏数据、性能瓶颈、路由 404 三座大山代码写出来能跑是一回事跑得顺畅、演示不出洋相是另一回事。开发过程中我在三个地方卡过壳每一个都有代表性值得单独拿出来说。5.1 数据稀疏问题评分矩阵全是零怎么办协同过滤最怕的事情是矩阵太稀疏。想想你系统的真实情况30 个用户100 个景点平均每个用户看过 5 个景点那用户向量里 95% 的位置都是 0。极端情况下两个用户可能根本没有共同评分的景点余弦相似度直接是 0。我实测下来最有效的方案是数据初始化时造一批合理的种子数据。写一个 SQL 脚本随机给 20~30 个用户分配 5~10 条评分记录评分范围控制在 2~5其中 4 分和 5 分多一些2 分少一些——现实中用户更愿意给好评不满意的往往直接不评。这样矩阵的稀疏度就从 95% 降到了 80% 左右虽然还是很稀疏但至少大部分用户之间能找到一个共同评分的景点算法就能转起来。还有一招是降低相似度计算的零值影响。两个用户哪怕只有一个共同景点也能计算出相似度但置信度很低。我加了过滤条件共同评分景点数少于 2 的相似用户直接不计入 Top-K。5.2 性能瓶颈相似度计算的复杂度问题同样注意的还是性能和内存问题。用最简单的方式算每个用户都要和所有其他用户算一次相似度。用户量 100 以下完全没压力但如果让我重新做我会在 Service 层加一个缓存把已经算好的相似度存到一个全局 Map 里用户行为变化时才更新。在实际项目里这个设计能避免每次请求都重复做全量计算。我的具体做法是数据量小不折腾直接每次现算。但代码里我预留了RecommendCache接口注释里写清楚如果用户量超过 1000用定时任务每 10 分钟刷新一次相似度矩阵。这个预留升级空间的设计答辩时也能作为优化思路讲。5.3 Vue Router 刷新 404 的问题前端有一个很经典的坑项目用了 Vue Router 的 history 模式路由路径类似/recommend、/scenic/3。本地开发时一切正常但npm run build之后部署到服务器刷新页面直接 404。原因很简单history 模式下浏览器访问/scenic/3会向服务器请求这个真实路径但服务器上根本没有这个文件。开发服务器 dev-server 做了 fallback 处理生产环境的 Nginx 没做。解决方案有两种最简单路由改成 hash 模式URL 变成/#/scenic/3不向服务器发真实请求刷新不会 404。配 Nginx在配置里加一个location /的 try_files 回退location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }我最后用了方案 2因为 URL 更干净而且 Nginx 配置写出来也能体现你懂部署。5.4 前后端联调的跨域问题开发环境下前端跑在localhost:5173后端跑在localhost:8080Axios 请求必然触发跨域。我在后端写了一个全局 CORS 配置类比在每个 Controller 上加CrossOrigin好管理得多Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意一个细节allowedOrigins(*)在allowCredentials(true)的情况下会被 Spring 拒绝必须用allowedOriginPatterns(*)。这个坑我刚开始没注意报错信息还不怎么友好卡了一会儿才查到。6. 从开发到交付把项目从能跑变成能演示最后一个环节往往是最容易翻车的。代码在本地 IDE 里跑得好好的一到演示环节要么数据库连不上要么前端构建产物路径不对。这一章给出一套我实测可行的部署和演示流程。6.1 数据库准备两步导入第一步MySQL 里创建数据库并导入初始化 SQL。我用的是 Navicat也可以直接命令行mysql -u root -p create database travel_recommend default character set utf8mb4; use travel_recommend; source /path/to/travel_recommend.sql;初始化脚本里除了建表语句还包含了种子数据10 个测试用户、50 个景点、几百条评论记录。建议你单独准备一个新的用户去测试推荐效果比如初始化脚本里专门造两个消费行为有明显偏好差异的用户一个只逛自然风光类景点一个只逛人文历史类景点。演示时一登录推荐列表的差异立刻能看出来这比嘴上说算法很准有说服力得多。6.2 后端打包与配置后端打包前先检查application.yml里的数据库连接配置。因为不同人的 MySQL 账号密码不一样最好统一从环境变量读取spring: datasource: url: jdbc:mysql://localhost:3306/travel_recommend?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: ${DB_USERNAME:root} password: ${DB_PASSWORD:123456}打包命令mvn clean package -DskipTests生成的 JAR 在target目录下运行java -jar target/travel-recommend-0.0.1-SNAPSHOT.jar6.3 前端构建与 Nginx 托管前端构建很简单npm install npm run build产物在dist目录。我习惯用 Nginx 托管server { listen 80; server_name localhost; root /opt/travel/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这段配置里有两个关键点try_files解决 Vue Router history 模式的刷新 404location /api/做反向代理这样前端请求/api/xxx时不会命中静态资源而是转发到后端服务。前端 Axios 的baseURL也可以直接改成相对路径/api省掉写死localhost:8080的麻烦。6.4 演示现场的几个实操细节最后说几个演示时的实操细节。第一演示前先在后台把推荐接口调一次确保算法模块预热完成不要现场等待第一次计算。第二至少准备两个差异明显的账号展示不同推荐结果时切换登录评委对个性化的感受会非常直观。第三提前准备好热点数据——比如把某个冷门景点的评分数据手动补一条让推荐列表里有几个偏门又合理的推荐结果这样比全是热门景点更有推荐的感觉。我最终把项目完整跑通、部署到一台备用服务器上之后又调整了一遍种子数据中的评分分布。几次实测下来最深的体会是推荐系统项目里算法本身并不难难的是让整个链路稳定地在页面上呈现出直观效果。数据造得好不好直接决定了演示时算法的聪明程度。这个项目做完之后我还把它扩展了一版在物品相似度计算时加入了地区特征同地区的景点相似度加一个小权重效果比纯行为数据更稳。如果你也需要做类似的项目可以从这里出发继续玩下去——协同过滤的变体、冷启动策略、混合推荐每一个方向都值得探索。
返回列表