☰
Spring Boot+协同过滤:自助旅游推荐系统开发实战
2026/10/10 6:32:11 网站建设 项目流程

1. 项目定调:自助旅游推荐系统到底要解决什么问题

1.1 需求分析不能只写“让游客更方便”

做这个题目之前,很多同学会直接打开文档模板,把“随着旅游业发展,人们生活水平提高……”抄一遍。我最初也差点这么干,但真正画原型图的时候才发现,需求分析写得越空,后面写代码越不知道从哪里下手。

我重新理解了一下“自助旅游推荐系统”这几个字。它服务的对象是自助游客——不跟团、自己规划行程的人。这类人真正头痛的问题有三个:第一,全网搜索攻略太累,信息零散;第二,看到一个目的地,不确定是否适合自己;第三,规划路线时对景点之间的位置、玩法定档没有概念。所以系统要解决的核心问题不是“能搜到景点”,而是“在用户没有明确目标的时候,能给他一个合理的、个性化的选项”。

站在这个角度,功能需求就清晰了。前台必须提供景点浏览、关键字搜索、分类筛选、景点详情、游客评分、收藏列表、个性推荐这些能力。后台则至少要能维护景点分类、景点介绍、景点图片、推荐权重配置,以及用户和评论的审核管理。这其实就是整个项目的功能骨架。

1.2 “自助”和“推荐”怎么平衡

我发现很多人会把“推荐”做成一个单纯的热门榜,把“自助”做成一个普通的后台CRUD。两个词看起来都实现了,但产品逻辑是断裂的。

我的理解是:推荐系统要给用户“可解释”的选项,而不是随机丢出一个景点列表。自助则意味着用户有权修正推荐结果。所以我在设计时增加了一个筛选条件栏,用户可以在推荐接口的参数里带上地区、景点类别、预算范围,推荐引擎在候选集生成阶段就把不符合条件的景点过滤掉。这才是“自助+推荐”的结合。

另一个容易被忽略的点是推荐结果的新鲜感。如果用户今天看的全是海滩,明天再打开还是海滩,那就成了信息茧房。所以我在生成推荐时引入了一个“探索因子”——在最终列表中保留一部分用户偏好之外但评分较高的景点,让用户偶尔有“惊喜”。这个设计不是算法上的创新,但对体验的提升非常明显,建议做同类项目的同学考虑。

1.3 确定了功能边界,再谈设计和实现

功能边界明确之后,就可以拆模块了。我最终把系统分成了前台用户子系统和后台管理子系统两条线。

前台用户子系统包括注册登录、个人中心、景点展示、景点搜索、分类筛选、景点详情、用户评分、景点收藏、推荐列表。后台管理子系统包括管理员登录、景点分类管理、景点信息管理、景点图片管理、用户管理、评论管理、推荐参数配置。模块拆完之后,工作量评估和排期就有了依据。整个开发周期大概五周,前两周做数据库和基础框架,中间两周做前后台核心功能,最后一周专门打磨推荐算法和界面交互。

2. 技术选型与开发环境:为什么选Java + B/S架构

2.1 B/S架构对这个项目意味着什么

B/S架构,通俗地讲就是浏览器/服务器架构,客户机只需要一个浏览器就能访问系统,不需要安装专门的客户端。对于旅游推荐这种以信息展示和交互为主的系统,B/S架构的优势非常明显:信息类系统重在随时随地的阅读和查询,浏览器访问最轻量,用户几乎零门槛。

从开发者的角度来看,B/S架构最大的好处是维护升级集中在服务器端。后台功能改一个接口、修一个Bug,前端只需要刷新浏览器就能拿到最新版本,这对项目演示和交付非常友好。尤其在做毕设或者课程设计的时候,评委老师很可能用不同的电脑访问你的系统,如果还要装客户端,现场就非常尴尬。另外,Java生态在B/S架构下的成熟度极高,从Servlet到Spring Boot,几乎所有需求都能找到现成的轮子,这也是我毫不犹豫选择Java技术栈的原因。

2.2 具体技术栈选择:我用的组合和理由

技术栈我选择了经典的“前后端分离 + 单体后端”模式:

  • 后端:Spring Boot 2.x + MyBatis
  • 数据库:MySQL 8.x
  • 缓存:Redis(用来存热门推荐结果和用户行为临时数据)
  • 前端:Vue 2 + Element UI(后台管理用),前台页面用原生HTML + Layui
  • 开发工具:IDEA + Navicat + Postman

Spring Boot 2.x 是目前Java Web毕设项目里最稳妥的选择,它统一了配置和依赖管理,内嵌Tomcat,部署时直接打jar包跑起来就行,比SSM那套手工配置省太多时间。MyBatis上手快,SQL可控性强,查询旅游数据这种多表关联、多条件筛选的场景,比JPA更容易优化。Redis不是必须的,但我加了它之后,首页热门推荐接口的响应时间从40毫秒降到了6毫秒,效果立竿见影。

如果你不想用前后端分离,也可以直接用Thymeleaf模版引擎把页面和后端写在一起。这样联调成本更低,但前后端代码耦合度高一些。我个人推荐用前后端分离,一方面更贴近目前企业的开发模式,另一方面答辩时被问前端交互逻辑,你可以讲得更清楚。

2.3 环境搭建中最容易拖后腿的三个细节

第一步就是把开发环境跑通,但我见过太多同学卡在环境里出不了坑。说三个最典型的:

一是JDK和Spring Boot版本不匹配。比如Spring Boot 2.x对JDK8/11都有很好的支持,但如果你用了JDK17,某些版本可能会出现CGLIB代理相关的诡异问题。所以我建议直接用JDK8,兼容性最好。

二是Maven依赖下载坑。第一次加载Spring Boot项目时,Maven要下载上百个依赖包,如果网络不稳定或者镜像仓库没配好,能折腾半天。解决方案是在~/.m2/settings.xml里配置阿里云镜像,同时把IDEA的Maven仓库路径指到本地仓库。

三是MySQL字符集和时区设置。建库时就明确utf8mb4,并加上serverTimezone=Asia/Shanghai连接参数,否则后面插入中文景点名会乱码,时间字段还会差八小时。这些细节不值得反复调试,一开始配对就是效率。

3. 数据库设计爱的人:旅游数据建模的完整思路

3.1 核心表结构:一张景点表藏不住整个业务

很多初学者会把所有景点信息塞进一张表,然后代码里反复查“SELECT * FROM scenic”。但在这个系统里,景点信息其实要服务两个业务方向:一是前台的展示搜索,二是推荐算法的数据输入。如果只靠一张表,推荐计算几乎无从下手。

我最终设计了六张主要表:

  • user:用户表,包含用户名、密码(MD5加密存储)、昵称、头像、注册时间、状态。
  • scenic:景点表,包含名称、所属分类ID、所在地区、简介、详细描述、封面图路径、门票价格、游玩时长、评分汇总值、评分人数、创建时间。
  • category:景点分类表,比如自然风光、人文古迹、主题乐园、城市观光、乡村古镇等。
  • comment:评论表,关联用户ID和景点ID,记录评论内容、评分值、点评时间。
  • favorite:收藏表,用户和景点的多对多关系表,记录收藏时间。
  • rating:评分表,记录用户对景点的评分,可以看作是推荐算法的核心行为数据源。

除了这些,我还加了一张recommend_log推荐日志表,记录每次请求推荐接口时用户和返回的景点列表,这个表对测试推荐效果非常有用。

3.2 字段设计里的几个关键决定

在设计scenic表时,有一个容易被忽视的点是“评分汇总值”。很多系统只记录用户每次评的分数,然后页面显示时用实时AVG计算。如果景点数量一多、评分记录一多,这个查询就会变慢。我的做法是在scenic表里冗余两个字段:total_score和score_count,用户提交评分时,后端在一个事务里更新这两个字段,查询详情时直接用除法展示,性能非常好。

另外,景点描述建议单独放一个detail字段,不要在设计列表页的时候把它查出来。列表页只需要简介、图片、评分、价格这些短字段,大文本字段在列表查询里只会拖慢分页速度。做系统的时候多用“查询字段最小化”的思路,前期不觉得,数据量上来就是天壤之别。

索引方面,我建了三个:category_id、area和name的模糊搜索索引。因为推荐引擎要按分类过滤,前台要根据地区筛选,搜索框要支持景点名模糊匹配。注意模糊查询如果用了%关键字%,普通索引是失效的,这时候要权衡是加全文索引还是牺牲一点性能。对毕设项目来说,数据量不大,普通索引+like查询就够了,但如果写成 “John” 这种形式的字段,应该用CONCAT('%', #{keyword}, '%'),SQL注入风险要避开。

3.3 建表SQL的核心部分

分享一段核心建表SQL,字段类型和逻辑都验证过:

CREATE TABLE `scenic` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL COMMENT '景点名称', `category_id` INT NOT NULL COMMENT '分类ID', `area` VARCHAR(50) DEFAULT '' COMMENT '所在地区', `description` VARCHAR(500) DEFAULT '' COMMENT '景点简介', `detail` TEXT COMMENT '景点详细介绍', `image` VARCHAR(255) DEFAULT '' COMMENT '封面图', `price` DECIMAL(10,2) DEFAULT '0.00' COMMENT '门票价格', `play_time` VARCHAR(20) DEFAULT '' COMMENT '建议游玩时长', `total_score` DECIMAL(10,2) DEFAULT '0.00' COMMENT '评分总分', `score_count` INT DEFAULT '0' COMMENT '评分人数', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_area` (`area`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点信息表';

需要注意DECIMAL不要定义成DECIMAL(2,2),评分总分可能累计很大,我用的DECIMAL(10,2)足够。另外,外键我并没有在数据库层面强制建,因为MyBatis下关联查询更灵活,外键约束反而容易在删除分类时牵扯麻烦。逻辑关联通过Java代码控制就行,这也是主流互联网项目习惯的做法。

4. 推荐引擎怎么落地:从热门榜到混合推荐

4.1 推荐系统第一步:在冷启动阶段怎么做

推荐引擎最难的不是算法本身,而是没有数据的时候怎么让用户感觉“推荐得挺靠谱”。新系统没有用户行为数据,如果一上来就协同过滤,算出来的是空矩阵。所以我分了两个阶段。

第一阶段做冷启动推荐。规则很简单:计算所有景点的一个综合得分,公式是综合得分 = 平均评分 * 0.6 + 收藏数/100 * 0.3 + 评论数/1000 * 0.1。这个公式可以保证评分高、人气高的景点排在前面,同时不会被少数极端评分带偏。把得分Top 10的景点作为推荐列表放在首页“热门推荐”区域。第二阶段,当某个用户的收藏数和评分记录到达一定阈值(比如收藏超过5个),系统自动切换到个性化推荐模式。

4.2 基于用户的协同过滤:算法思路与Java实现

个性化推荐我主要用了基于用户的协同过滤(UserCF)。它的核心思想很朴素:找到和你兴趣最相似的几个用户,把他们喜欢而你没接触过的景点推荐给你。

具体做法分三步:

  1. 构建用户-景点评分矩阵。我从rating表和favorite中提取行为数据,评分为1~5分对应1.0~5.0,收藏行为计为4.0,浏览行为计为1.0。这样数据稀疏度会低很多。
  2. 计算用户相似度。我选用余弦相似度,公式为cos = (A·B) / (|A|*|B|)。在Java里实现时,不需要真正的数组,可以用Map<Integer, Map<Integer, Double>>来模拟稀疏矩阵。
  3. 为目标用户生成推荐列表。找到Top 5相似用户之后,把他们评过分的景点汇总起来,去掉目标用户已经收藏或已经评分的,按“相似用户平均分加权”排序。

核心伪代码如下:

public List<Integer> recommend(Integer userId, int topN) { Map<Integer, Double> userVector = buildUserVector(userId); Map<Integer, Double> simMap = new HashMap<>(); for (Integer otherId : allUserIds) { if (otherId.equals(userId)) continue; Map<Integer, Double> otherVector = buildUserVector(otherId); double sim = cosineSimilarity(userVector, otherVector); if (sim > 0.1) { simMap.put(otherId, sim); } } // 取前5个相似用户 List<Map.Entry<Integer, Double>> topUsers = topK(simMap, 5); Map<Integer, Double> scoreMap = new HashMap<>(); for (Map.Entry<Integer, Double> entry : topUsers) { Integer other = entry.getKey(); Double sim = entry.getValue(); Map<Integer, Double> ratingMap = ratingsByUser(other); for (Map.Entry<Integer, Double> r : ratingMap.entrySet()) { if (!userVector.containsKey(r.getKey())) { scoreMap.merge(r.getKey(), r.getValue() * sim, Double::sum); } } } return topK(scoreMap, topN).stream() .map(Map.Entry::getKey).collect(Collectors.toList()); }

这个代码在数据量很小的时候完全够用。景点数量几百个、用户几十个,跑一次推荐只需要几十毫秒。但如果用户数量和景点数量都上千,建议提前做离线计算,把相似度矩阵存在Redis里,线上接口只做查询。我的优化就是每天凌晨用定时任务重新计算一次相似度矩阵,白天接口直接读缓存。

4.3 基于内容的推荐兜底:标签匹配更稳定

协同过滤有一个问题:新用户或者行为少的用户,算出来的推荐列表可能会非常“偏科”。比如一个用户只收藏了两个海滩,系统就会一直推海滩,完全没有多样性。所以我又加了一个基于内容的推荐作为兜底。

做法是给每个景点打标签,例如“亲子”“古城”“摄影”“免费”“爬山”“海滨”。这条信息可以在后台维护景点时填。用户行为少时,根据他收藏景点的标签频次生成一个偏好向量,比如喜欢“古城”和“摄影”,然后计算每个景点标签和偏好向量的重合度,按重合度排序。

这样做的好处是解释性非常强。前端可以给出推荐理由——“因为你对‘古城’‘摄影’感兴趣,推荐你去XX古镇”。为了让用户信服推荐系统,解释提示很重要。最终线上接口采用加权混合策略:个性化协同过滤占70%,内容推荐占20%,热门推荐占10%,既保证精准度,又有一定的多样性和兜底能力。

5. 系统开发中的模块拆解与关键接口设计

5.1 用户模块:Session和拦截器怎么配合

用户模块虽然简单,但却是最容易出安全漏洞的地方。我使用了Session机制来维持登录状态,配合Spring Boot提供的拦截器统一校验。

核心逻辑是:用户登录成功后,将用户信息存入session,设置过期时间;前端每次发起请求时携带JSESSIONID,后端通过拦截器判断session中是否存在当前用户,如果不存在直接返回未登录状态码。

需要特别处理的一个细节是“记住我”功能。很多人用Cookie明文存用户名密码,这是非常糟糕的做法,明文存密码等于把账户信息暴露给浏览器。我采用的是“自动登录令牌表”方案:用户勾选记住我后,服务端生成一个随机token,把token存到数据库,同时通过Cookie返回给前端。用户再次访问时,服务端用token换取登录状态,保证密码不会以任何形式出现在浏览器中。

接口设计上,登录接口是标准的/user/login,支持用户名+密码;注册接口需要校验用户名唯一性,密码存储先用MD5加盐,再做一次SHA256散列,比直接存明文强太多。

5.2 推荐接口:返回内容不只是景点列表

推荐接口/api/recommend/list是我花时间最多的一个接口。它的参数设计为:userId、categoryId、area、page、pageSize。服务端首先生成候选推荐列表,再根据筛选条件过滤,最后做分页返回。

最开始我只返回景点基本信息,后来发现前端展示时还需要知道“推荐理由”,所以我在返回实体RecommendVO中增加了reason字段,例如“因为你对自然风光有偏好,推荐该景点”。这个字段的来源在4.3节提到的内容匹配逻辑中已经算好了,前端展示时直接显示成一行小字,用户会觉得这个系统是真正理解他的。

还需要说明的是分页与推荐质量的权衡。推荐列表一般是按综合分排序,然后再分页。如果用户翻到第二页,我们不能重新算一个推荐列表,而是要把用户浏览过的不再展示,继续展示后续的候选项。我用一个Redis Set存储每次推荐请求返回给该用户的所有景点Id,下一次取下一页时把这个Set传入算法排除,这样能有效避免重复推荐。

5.3 后台管理模块:数据灌入靠批量导入

后台管理模块的开发比较CRUD,但有一个功能对后期的推荐效果锤至关重要——批量导入景点数据。因为手工录入几百条景点信息效率太低,我用POI解析了Excel模板,支持一次导入最多200条景点记录。导入时自动为每个景点生成标签,用空格分隔,存到scenic表新增的tags字段里。管理员后台也可以对单条景点进行标签编辑,这为内容推荐提供了关键数据源。

另一个亮点是数据统计面板。我用了ECharts展示三个图表:景点分类占比饼图、各景点评分Top10柱状图、每周用户新增/收藏数折线图。这些图表的数据全部来自统计查询SQL,使得后台看起来不再是一个简陋管理系统,而是有“运营分析”味道的产品。

6. 联调阶段的经典坑与排查思路

6.1 前后端分离的跨域问题

开发时前端在8080端口,后端在8081端口,浏览器直接请求后端接口时,大概率会报跨域错误。刚开始我以为是后端Api写错了,后来打开浏览器的Network面板才发现,请求被浏览器拦截了,根本到不了后端。

解决办法是在Spring Boot后增加一个全局CORS配置类:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:8080") .allowedMethods("*"); } }

如果上线部署时前后端在同一个域下,这个配置可以不用。但对于本地开发,这个配置能让联调效率起飞,强烈建议加上。

6.2 中文乱码问题:从数据库到页面链路排查

中文乱码这个问题,90%的情况下出在连接层。我排查过一次,发现页面上显示了正常的景点名,但搜索“鼓浪屿”时查不到数据。最后发现是MySQL连接字符串里漏了characterEncoding=utf8。加上之后一切正常。

还有一个隐蔽的点是Linux服务器的默认编码是POSIX,如果部署环境的LANG环境变量没有配置,Java运行时的默认字符集可能不是UTF-8,会出现“本地没事,服务器上乱码”的现象。我的建议是在后端启动脚本中强制添加JVM参数-Dfile.encoding=UTF-8,从源头上保证编码一致性。

6.3 推荐性能优化:缓存穿透和结果去重

上线后某天高峰期,推荐接口平均耗时飙升到了300毫秒,这显然不满足“快速展示”的预期。经过日志分析,问题出在协同过滤的实时计算上。虽然我做了每日离线相似度矩阵,但用户行为向量和候选集过滤还是实时计算的。

我的优化方案是:

  1. 离线计算好推荐结果后,按不同用户分组写入Redis,缓存2小时。缓存未命中时才实时计算,并且设置了单用户并发请求锁,防止多个请求同时触发实时计算。
  2. 推荐结果在写缓存前做严格去重。同一个景点在一个用户的不同推荐结果中,这是由缓存覆盖不及时导致的,修改后在写入前用Set判断即可。
  3. 首页热门推荐接口直接缓存整个榜单,缓存键加当天日期,每天凌晨过期。这样热门接口永远是最快的。

优化后80%的请求直接命中缓存,接口平均耗时稳定在10毫秒以内,用户体感提升非常明显。

6.4 测试与答辩前的自检清单

最后分享一下我整理的自测表单,覆盖功能、性能和交互三个方面。这也是我建议所有做类似项目的人,在答辩或展示前一天逐项过一遍的清单。

检查项预期结果
注册新账号用户名唯一性校验,非法字符拦截
登录流程密码错误有提示,连续失败5次锁定30分钟
景点搜索支持模糊查询,搜索词高亮显示
景点详情页页面加载速度小于1秒,图片有懒加载
提交评分总分和评分人数同步更新
收藏功能重复收藏有提示,撤回后收藏数减1
个性化推荐同一用户重复请求结果不重复
后台管理登录非法访问被拦截器拦截
批量导入Excel数据校验失败行有明确错误提示
游客访问所有数据接口正常返回,个人信息接口返回401

这份表单虽然简单,但每一项都对应一个可能被答辩老师追问的实现细节。把这些问题提前验证,心里就有底了。

7. 推进项目过程中的个人体会

这次做完整个系统的最大感受是:一个基于B/S架构的Java项目,真正锻炼人的不是会写几个Java类,而是在“设计”和“实现”之间反复平衡的能力。

说实话,推荐算法在学术上有无数高深的模型,但落到一个毕设级别的系统里,最先要解决的是数据从哪来、怎么让页面有反应、怎么让用户觉得“它真的懂我”。我从热门榜改到协同过滤,再补内容推荐,来回改了三个版本,最后发现混合策略的稳定性远超单一算法。这个“先跑通再优化”的思路,对任何项目都适用。

我还有一个体会是,数据库设计比代码更需要耐心。很多表哥看起来很合理的表,在真正接推荐算法的时候就会卡住,比如没有行为日志表、字段类型不匹配、没有预留标签字段。我建议所有想做类似题目的同学,拿到需求后先花三天时间把表和字段设计扎实,再去写代码。好的表结构能让你少写一半的业务逻辑代码。

最后想提醒一点:项目演示时,不要只展示“按照电影打分推荐”这种教科书场景。我给自己的系统录了一段演示,重点放在“新用户第一次进系统看到的是什么”“收藏5个景点后推荐列表发生了什么变化”“后台管理员调整景点标签后推荐结果如何响应”这三个场景。这些才是把这个题目做成“作品”的关键差异点。

如果你正在做同样的课题,希望这篇记录能帮你少踩几个坑。遇到具体问题的时候,欢迎按类似的思路去排查——先确认环境,再看日志,最后想业务逻辑的边界,通常问题都出在这三层的某一层。祝你也顺利把一个看起来普通的题目,做成自己满意的系统。

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

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

立即咨询