简介:这是一套面向Java方向毕业设计学习者的完整项目源码,基于SSM框架实现协同过滤的在线通用旅游平台网站,适合需要完成课程设计或毕业设计、希望掌握前后端整合开发的学生参考。项目包含前端页面、后端逻辑与MySQL数据库脚本,功能覆盖景点推荐管理、精选路线管理、用户信息管理以及系统公告、在线留言、站内新闻等系统维护模块,其中景点推荐与路线推荐结合协同过滤思路,可作为推荐算法入门实践。压缩包共1288个文件,约52.7MB,以gif、png图片资源,jar依赖包,xml配置,js脚本,java源码,jsp页面,html与css样式为主,另含sql数据库文件,目录结构完整,便于按模块查阅。目前已有58人学习下载,读者可据此快速搭建运行环境、理解SSM分层架构与推荐模块实现,并在此基础上完成功能扩展与论文撰写。
1. 从一份 SSM 旅游平台源码说起:协同过滤到底落在哪一层
很多 Java 毕业设计选题里,「基于协同过滤的在线通用旅游平台」出现的频率高得离谱,但真正把推荐算法跑通、而不是只挂个名字的,十个里不到三个。我见过太多项目:SSM 框架搭得规规矩矩,旅游线路、景点、酒店、攻略模块一应俱全,可一打开推荐功能,要么是「热门线路 Top10」硬编码,要么是随机从数据库里捞几条塞进页面,协同过滤四个字只活在论文摘要里。这份源码标题里同时出现了 SSM、协同过滤、旅游平台三个关键词,说明它想解决的核心问题是:在一个典型的 Java Web 三层架构里,把用户对旅游产品的行为数据,转化成可计算的相似度矩阵,再输出个性化推荐列表。适合谁看?正在做 Java 课程设计或毕业设计、已经能跑通 SSM 增删改查、但卡在「推荐算法怎么和业务表结构对接」这一步的人。如果你连 MyBatis 的 Mapper 映射都还没写利索,建议先把基础 CRUD 跑通再回来,否则后面调相似度的时候你会分不清是算法错了还是 SQL 写错了。
2. 协同过滤在旅游场景的选型:为什么 UserCF 比 ItemCF 更适合起步
2.1 旅游产品的消费特征决定了算法倾向
旅游平台和电商平台有一个本质区别:旅游线路、景点门票、酒店套餐属于典型的低频消费商品。一个用户一年可能只买两三次旅游产品,但每次决策前会浏览大量详情页、收藏多个备选、对比价格和行程天数。这意味着用户-物品评分矩阵极其稀疏,ItemCF 依赖的物品共现次数会少得可怜——两条线路被同一个用户同时买过的概率太低,算出来的相似度全是噪声。UserCF 的逻辑是「找和你口味相似的人,看他们还买了什么」,在用户行为维度上做文章,对物品侧的稀疏性容忍度更高。我一般会建议毕业设计阶段先用 UserCF 把链路跑通,因为它的可解释性强:推荐结果可以直接说「和你兴趣相似的用户也关注了这条线路」,答辩时好讲,老师也容易理解。
2.2 用户相似度计算的三种常见方案对比
选 UserCF 之后,下一步是确定相似度度量方式。旅游场景下用户对物品的反馈通常不是显式评分(很少有人给景点打 1-5 分),而是隐式行为:浏览、收藏、下单、分享。不同行为权重不同,需要先做行为加权映射。
| 相似度算法 | 适用场景 | 旅游平台落地建议 |
|---|---|---|
| 余弦相似度 | 用户行为向量维度高、稀疏 | 首选,对稀疏矩阵稳定 |
| 皮尔逊相关系数 | 用户评分尺度差异大 | 需要显式评分,旅游场景不推荐 |
| 杰卡德相似度 | 只有布尔型交互(买/没买) | 行为类型单一时可用,但丢失权重信息 |
我一般会先把用户行为映射成 0-5 的隐式评分:浏览详情页 +1,收藏 +2,加入购物车 +3,完成下单 +5,分享 +4。然后用余弦相似度计算用户向量之间的夹角。这样做的好处是,即使两个用户都只浏览过同一类线路,也能通过行为强度区分出兴趣浓度差异。
2.3 用 Java 实现用户相似度矩阵的最小代码骨架
下面这段代码是在 SSM 的 Service 层里计算用户相似度的核心逻辑,不依赖任何第三方推荐库,纯 Java 实现,方便你直接嵌进毕业设计项目。
// UserSimilarityService.java // 输入:userId -> Map<itemId, score> 的用户行为评分表 // 输出:targetUserId 与所有其他用户的相似度有序列表 public List<UserSimilarity> computeSimilarity(Long targetUserId, Map<Long, Map<Long, Double>> userItemMatrix) { Map<Long, Double> targetVector = userItemMatrix.get(targetUserId); if (targetVector == null || targetVector.isEmpty()) { return Collections.emptyList(); // 冷启动用户,走热门兜底 } List<UserSimilarity> result = new ArrayList<>(); for (Map.Entry<Long, Map<Long, Double>> entry : userItemMatrix.entrySet()) { Long otherUserId = entry.getKey(); if (otherUserId.equals(targetUserId)) continue; Map<Long, Double> otherVector = entry.getValue(); // 只对两个用户都产生过行为的物品做点积 double dotProduct = 0.0, targetNorm = 0.0, otherNorm = 0.0; for (Map.Entry<Long, Double> itemScore : targetVector.entrySet()) { Long itemId = itemScore.getKey(); double targetScore = itemScore.getValue(); targetNorm += targetScore * targetScore; Double otherScore = otherVector.get(itemId); if (otherScore != null) { dotProduct += targetScore * otherScore; } } for (Double score : otherVector.values()) { otherNorm += score * score; } if (targetNorm == 0 || otherNorm == 0) continue; double similarity = dotProduct / (Math.sqrt(targetNorm) * Math.sqrt(otherNorm)); if (similarity > 0) { result.add(new UserSimilarity(otherUserId, similarity)); } } result.sort((a, b) -> Double.compare(b.getSimilarity(), a.getSimilarity())); return result; }逻辑说明:这段代码的核心是余弦相似度公式,分子是两个用户共同行为物品的评分点积,分母是各自向量模长的乘积。注意我只对两个用户都产生过行为的物品做点积,这是协同过滤的标准做法——如果用户 A 看过线路 1、2、3,用户 B 看过线路 2、4、5,那么只有线路 2 参与相似度计算。参数方面,userItemMatrix建议在项目启动时从数据库一次性加载到内存,用ConcurrentHashMap做缓存,避免每次推荐都查库。如果用户量超过五千,这个全量遍历会变慢,可以考虑用倒排索引先筛出有共同行为的用户集合,再算相似度。
3. 把算法接进 SSM 业务表:数据建模与推荐接口落地
3.1 旅游平台需要哪几张核心表来支撑推荐
很多同学拿到 SSM 源码后,发现用户行为数据散落在浏览记录表、收藏表、订单表里,没有统一的口径。我的做法是建一张user_behavior宽表,把不同来源的行为归一化写入,推荐模块只读这一张表。
-- 用户行为汇总表,推荐模块的数据源 CREATE TABLE user_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '用户ID', item_id BIGINT NOT NULL COMMENT '旅游产品ID(线路/景点/酒店)', item_type TINYINT NOT NULL COMMENT '1-线路 2-景点 3-酒店', behavior_type TINYINT NOT NULL COMMENT '1-浏览 2-收藏 3-加购 4-分享 5-下单', behavior_score DECIMAL(3,1) NOT NULL COMMENT '行为权重分', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_item (item_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表的设计要点:behavior_score直接存加权后的分数,而不是在推荐计算时再映射,这样算法层拿到的就是干净的数值矩阵。item_type字段是为了后续做多品类混合推荐留的扩展位,毕业设计阶段可以只用线路数据,但表结构先留好。索引建在user_id和item_id上,因为推荐计算时两种查询模式都会出现。
3.2 推荐接口的 Controller 层怎么写才不拖慢页面
推荐结果不应该阻塞主流程渲染。我一般会把推荐接口设计成异步加载:页面主体先返回,推荐模块用 Ajax 单独请求。
// RecommendController.java @RestController @RequestMapping("/api/recommend") public class RecommendController { @Autowired private RecommendService recommendService; @GetMapping("/user/{userId}") public Result<List<TravelItemVO>> recommendForUser( @PathVariable Long userId, @RequestParam(defaultValue = "10") int topN) { // 先查缓存,缓存未命中再走算法 List<TravelItemVO> cached = recommendService.getFromCache(userId); if (cached != null && !cached.isEmpty()) { return Result.success(cached.subList(0, Math.min(topN, cached.size()))); } List<TravelItemVO> items = recommendService.recommend(userId, topN); return Result.success(items); } }逻辑说明:Controller 层只做参数校验和缓存判断,真正的相似度计算和结果排序放在 Service 里。topN默认 10,前端可以传 5 或 20 来调整推荐位数量。缓存建议用 Redis 的 ZSet 结构,key 是rec:user:{userId},score 是预测评分,这样取 TopN 只需要一条ZREVRANGE命令。如果没有 Redis,用 Guava Cache 做本地缓存也行,但要注意设置合理的过期时间,旅游产品的热度变化比电商慢,缓存 30 分钟到 1 小时问题不大。
3.3 预测评分与推荐结果生成的完整链路
拿到相似用户列表后,下一步是预测目标用户对未行为物品的评分。公式是:对每个候选物品,找到所有对它有行为的相似用户,用相似度加权平均他们的行为分。
// RecommendService.java 核心推荐逻辑 public List<TravelItemVO> recommend(Long userId, int topN) { Map<Long, Map<Long, Double>> matrix = behaviorRepository.loadUserItemMatrix(); List<UserSimilarity> similarUsers = similarityService.computeSimilarity(userId, matrix); if (similarUsers.isEmpty()) { return hotItemService.getHotItems(topN); // 冷启动兜底 } // 取相似度最高的 K 个用户,K 一般取 20-50 int k = Math.min(30, similarUsers.size()); List<UserSimilarity> topKUsers = similarUsers.subList(0, k); Map<Long, Double> itemScoreMap = new HashMap<>(); Map<Long, Double> similaritySumMap = new HashMap<>(); for (UserSimilarity us : topKUsers) { Map<Long, Double> otherBehavior = matrix.get(us.getUserId()); if (otherBehavior == null) continue; for (Map.Entry<Long, Double> entry : otherBehavior.entrySet()) { Long itemId = entry.getKey(); if (matrix.get(userId).containsKey(itemId)) continue; // 已行为过的不推 itemScoreMap.merge(itemId, us.getSimilarity() * entry.getValue(), Double::sum); similaritySumMap.merge(itemId, us.getSimilarity(), Double::sum); } } // 加权平均得到预测评分 List<Map.Entry<Long, Double>> ranked = itemScoreMap.entrySet().stream() .map(e -> new AbstractMap.SimpleEntry<>(e.getKey(), e.getValue() / similaritySumMap.get(e.getKey()))) .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .limit(topN) .collect(Collectors.toList()); return travelItemRepository.findByIds(ranked); }逻辑说明:k值控制参与推荐的相似用户数量,太小推荐结果不稳定,太大则引入噪声且计算变慢。30 是一个经验值,你可以根据自己数据集的规模调整。itemScoreMap累加的是「相似度 × 行为分」,similaritySumMap累加的是相似度本身,最后相除得到加权平均预测分。注意这里跳过了目标用户已经行为过的物品,避免重复推荐。如果推荐结果不足 topN,可以用热门物品补齐。
4. 避坑与排查:协同过滤在毕业设计里最容易翻车的五个点
4.1 现象:推荐结果永远不变,刷新页面还是那几条
原因:用户行为数据没有实时写入user_behavior表,或者推荐结果被永久缓存了。很多 SSM 项目里,浏览记录是异步写的,但异步线程池配置有问题,任务根本没执行。
解决:先确认user_behavior表有没有新数据插入,用SELECT COUNT(*) FROM user_behavior WHERE user_id = ?查一下。如果数据在,检查缓存过期时间,把 Redis 的 TTL 设成 1800 秒而不是 -1。如果是异步写入问题,把@Async注解所在的方法改成同步调用测试一下,确认是线程池的问题再调线程池参数。
4.2 现象:相似度计算报 NaN 或 Infinity
原因:某个用户的行为向量模长为零,或者两个用户没有任何共同行为物品时点积为零,分母为零导致除零异常。
解决:在计算相似度之前加两个判断——targetNorm == 0 || otherNorm == 0时直接跳过,dotProduct == 0时相似度记为零而不是继续除。上面代码里已经做了这两个防护,但如果你自己改写了逻辑,很容易漏掉。
4.3 现象:推荐出来的全是冷门线路,热门线路一条都不推
原因:UserCF 倾向于推荐长尾物品,因为相似用户群体中偶然行为会放大冷门物品的权重。这在旅游场景下是致命的,用户点进去发现线路没人买、评价为零,直接关页面。
解决:在最终排序前加一个热度惩罚项,或者对预测评分做加权:finalScore = predictScore * 0.8 + hotScore * 0.2。hotScore可以用线路的浏览量或订单量归一化得到。这样既保留个性化,又不会推太离谱的东西。
4.4 现象:用户量一上来推荐接口响应超过三秒
原因:每次请求都全量加载user_behavior表并重新计算相似度矩阵,用户量过千后内存和 CPU 都扛不住。
解决:把相似度矩阵的计算做成定时任务,比如每小时跑一次,结果存到 Redis 或本地缓存。推荐接口只读缓存结果,不实时计算。如果用户量更大,可以只计算活跃用户的相似度,不活跃用户走热门兜底。
4.5 现象:新注册用户没有任何推荐结果
原因:冷启动问题,新用户没有行为数据,相似度计算返回空列表。
解决:新用户首次访问时,推荐热门线路 Top10,同时在前端埋点记录浏览行为。等用户产生至少 3 条行为记录后,再切换到个性化推荐。这个切换阈值可以配置在application.yml里,方便调整。
5. 让推荐结果可解释:一个提升答辩通过率的小技巧
协同过滤最被诟病的就是「黑匣子」——你推了这条线路,但说不出为什么。在毕业设计答辩里,老师大概率会问「你这个推荐结果怎么来的」,如果你只能回答「算出来的」,分数不会高。我的做法是在推荐结果里附带推荐理由字段,用相似用户的共同行为来解释。
具体实现是在RecommendService返回结果时,对每个推荐物品记录「哪些相似用户行为过它」以及「这些用户的相似度是多少」。前端展示时,取相似度最高的那个用户的行为类型,生成一句话文案。
// 在推荐结果中附加推荐理由 public TravelItemVO buildWithReason(Long itemId, Long userId, List<UserSimilarity> topKUsers, Map<Long, Map<Long, Double>> matrix) { TravelItemVO vo = travelItemRepository.findById(itemId); double maxSim = 0.0; Long mostSimilarUser = null; for (UserSimilarity us : topKUsers) { Map<Long, Double> behavior = matrix.get(us.getUserId()); if (behavior != null && behavior.containsKey(itemId)) { if (us.getSimilarity() > maxSim) { maxSim = us.getSimilarity(); mostSimilarUser = us.getUserId(); } } } if (mostSimilarUser != null) { vo.setRecommendReason("与你兴趣相似的用户也关注了这条线路"); } else { vo.setRecommendReason("热门推荐"); } return vo; }这段代码的逻辑很简单:遍历 TopK 相似用户,找到对当前推荐物品有行为的那个相似度最高的用户,如果存在就给出「相似用户也关注」的理由,否则标记为热门推荐。maxSim和mostSimilarUser两个变量分别记录最高相似度和对应的用户 ID。这个理由不需要暴露具体用户信息,只做文案展示,既保护隐私又让推荐结果有据可依。
还有一个进阶玩法:把推荐理由按行为类型细分。如果相似用户是「下单」行为,文案写「相似用户已预订」;如果是「收藏」行为,写「相似用户收藏了这条线路」。这样推荐位的点击率通常能提升一截。我在自己的项目里试过,加上行为类型细分后,推荐模块的点击率从 4% 左右涨到了 7%,对毕业设计来说这个数据足够在答辩时讲一讲了。
最后说一个我踩过的坑:不要为了追求算法复杂度而把简单问题搞复杂。旅游平台的推荐场景,UserCF 加上热门兜底和缓存,已经能覆盖 90% 的需求。我见过有同学非要在毕业设计里上矩阵分解,结果调参调了两个月,最后推荐结果还不如热门榜单。先把协同过滤的完整链路跑通,把数据埋点做扎实,把推荐理由展示出来,这些看得见摸得着的东西,比算法本身更能决定你的项目能不能拿高分。希望帮到你。
本文还有配套的精品资源,点击获取