☰
基于SpringBoot的个性化旅游景点推荐网站项目实战解析
2026/9/26 7:47:34 网站建设 项目流程

做 Java 旅游推荐类项目的时候,几乎所有人都会遇到同一个场景:景点库里躺着几千条数据,用户打开首页却不知道选哪个。个性化旅游景点推荐网站要解决的,就是这种“选择焦虑”。这类 Java 实战项目最近特别常见,核心其实就是用 SpringBoot 搭一个服务端平台,把景点展示、用户偏好分析、个性化推荐、行程规划、门票预订这些环节串成一条完整的业务链路。它不只是一个简单的 CRUD 后台,真正的价值在于“怎么根据用户行为,把合适的景点放在合适的推荐位”。

这篇文章我会从需求拆解开始,把表结构、推荐打分逻辑、SpringBoot 核心接口、预订并发控制都讲一遍,最后再分享一批我在实际项目里踩过的坑。如果你正在做 Java 方向的项目,想接触 SpringBoot 综合开发,或者对旅游推荐这类业务场景感兴趣,下面这些内容基本可以拿来当参考。

1. 项目概述与需求拆解:一台“懂用户”的旅游服务平台要做什么

1.1 个性化旅游推荐的业务痛点

传统旅游平台的首页,通常是一个“热门景点排行榜”加上搜索框。热门榜看着省事,但有个致命的问题:它是给所有人看的,不是给“当前这个用户”看的。一个经常带娃出门的用户和一个喜欢打卡拍照的用户,理想中的推荐列表应该是完全不同的。如果系统只按销量排序,亲子用户大概率会反复看到酒吧街、极限运动这类不匹配的内容,点了几次没兴趣,用户就走了。

个性化推荐网站要解决的问题,就是把“景点的属性”和“用户的偏好”做一个动态匹配。落到业务上,系统需要做三件事:第一,记录用户点击、收藏、下单等行为;第二,给每个景点打上可计算的标签;第三,用一套打分规则把行为数据和标签数据换算成一个推荐分,最后按分数排序展示。这三个环节缺一个,推荐就只是“伪个性化”,本质上还是热门排序。

这个项目里,我经常把业务模块拆成四个部分:用户端负责浏览和预订,推荐引擎负责生成候选列表,行程规划负责把景点排进具体某一天,管理后台负责维护景点与标签。四个部分之间通过 SpringBoot 的 Restful 接口通信,数据统一走 MySQL,热点数据走 Redis。整体逻辑不复杂,但每个部分都有不少细节。

1.2 为什么技术选型锁定SpringBoot

很多 Java 项目的老底子是 SSM,也就是 Spring + SpringMVC + MyBatis。SSM 不是不能做,但每次新建项目都要配一大堆 XML,数据源、事务管理器、视图解析器、过滤器,每一样都得手工声明。SpringBoot 把这些东西大部分收敛成了“自动配置”,一个 spring-boot-starter-web 依赖拉进来,写一个 main 方法就能启动 Web 服务。对旅游推荐这种业务链路比较长的项目,开发效率会差很多。

另外,SpringBoot 的生态对中小型系统特别友好。拿数据持久层来说,可以选 Spring Data JPA,也可以选 MyBatis-Plus,两者都能在 SpringBoot 里通过 starter 快速集成;推荐结果要缓存,直接 spring-boot-starter-data-redis;接口要做登录校验,可以用 Spring Security 或者写一个简单的 HandlerInterceptor。这种“要什么加什么”的方式,非常贴合业务迭代节奏。

从学习角度讲,SpringBoot 也是 Java 岗位面试里绕不开的关键词。做这个项目时,我建议不要停留在“能启动、能写接口”的层面,而是把自动配置原理、Bean 生命周期、事务传播机制这些底层问题一并搞明白。项目本身是业务载体,真正沉淀下来的,是你对 SpringBoot 运行时机制的理解。

1.3 功能模块规划:先画清楚边界

动手写代码之前,先花半小时把功能模块拆出来,比直接建表重要得多。我的习惯是画一张模块清单,区分“用户端”和“管理端”,再区分“核心流程”和“支撑流程”。

用户端核心流程有四个:

  • 用户注册登录:手机号或邮箱 + 密码,登录后签发 Token。
  • 景点浏览与搜索:支持按城市、类型、关键词筛选。
  • 个性化推荐:首页推荐、相似景点推荐、猜你喜欢。
  • 行程规划与预订:选择日期和城市,系统生成行程,并对行程中的景点门票下单。

管理端核心流程相对简单,主要是景点信息维护、标签管理、订单管理、用户行为查询。支撑流程包括 Redis 缓存、定时任务清理失效数据、统一异常处理、接口参数校验。

边界清楚了,后面建表、写接口、做分页都会有方向。很多项目做到一半改来改去,就是因为一开始没想清楚“推荐”和“搜索”到底是不是一回事。在这个系统里,搜索是用户主动表达需求,推荐是系统猜测用户需求,前者用 SQL 过滤,后者用打分排序,两者不能混在一个逻辑里。

2. 系统架构与核心数据模型设计

2.1 分层架构:从Controller到Mapper的职责划分

SpringBoot 项目最常用的分层方式是 Controller、Service、Mapper、Entity、DTO。很多新手喜欢把业务逻辑写在 Controller 里,接口一多就乱套。这个项目我推荐按下面这种结构组织:

  • controller:只接收请求参数,调用 Service,把 DTO 转成 JSON 返回。
  • service:处理业务逻辑,推荐算法、行程规划、下单事务都放在这一层。
  • mapper:操作数据库,一个方法对应一条 SQL 或一个 MyBatis 映射。
  • entity:数据库表的映射实体,字段和表结构保持一致。
  • dto:接口传输对象,按前端需要裁剪字段,避免把实体直接暴露出去。

实体和 DTO 为什么要分开?最典型的场景是推荐接口。景点实体里有创建时间、管理员 ID、上下架状态,前端根本不需要。如果你直接把实体序列化返回,不仅多传了一堆无用字段,还可能因为实体里的懒加载关联对象触发序列化异常。这个坑我后面会详细说。

2.2 数据库表设计:推荐系统能不能跑,靠的是这5张表

旅游推荐系统的表结构并不复杂,但设计时一定要围绕“标签”和“行为”这两个核心来建。我把最常用的几张表列出来:

表名作用核心字段
user用户基本信息id, nickname, phone, password, register_time
scenic景点信息id, name, city, address, cover_url, description, score, stock
tag标签字典id, tag_name, tag_type
scenic_tag景点与标签关联id, scenic_id, tag_id
user_behavior用户行为记录id, user_id, scenic_id, behavior_type, create_time

其中scenic_tag是典型的多对多关联表,用来打破景点与标签之间的“多对多”关系。user_behavior是整个推荐系统的数据基础,每一次点击、收藏、下单,都会往这张表里写一条记录。如果项目需要做更复杂的偏好分析,也可以加上user_tag_pref表,专门存储用户对某个标签的累计偏好分,避免每次推荐都实时扫描行为表。

在字段设计上,有几个细节值得强调。第一,scenic.stock必须设为非负并在 SQL 里加约束,这是后面做库存扣减的基础。第二,行为类型的字段不要用字符串乱写,最好用数字枚举,比如 1 浏览、2 收藏、3 下单。第三,所有关联字段都要建索引,尤其是user_behavior.user_id和scenic_tag.tag_id,不然推荐接口一上线就是慢查询。

2.3 用户行为与景点标签:推荐系统的“原料仓”

推荐系统能不能给出可信的结果,取决于“原料”干不干净。这里的原料就是用户行为和景点标签。景点标签要尽量可控,不要任由管理员随便乱填。我习惯在管理后台限制标签必须从字典里选择,并且规定一个景点最多打 5 个标签。太少了信息量不足,太多了会让相似度计算失去区分度。

用户行为的记录要选对埋点位置。比如浏览行为,应该记录在景点详情页的打开动作上,而不是列表页的曝光。因为列表页可能一口气展示了 20 个景点,用户只是扫了一眼,并不能说明他对这些都感兴趣。收藏和下单行为更接近真实意图,权重应该更高。

行为数据会有很多噪音。比如用户误点了详情页,刚进去就退出来,这条浏览记录如果参与打分,会把推荐带偏。我后来加了个简单过滤:浏览详情页超过 10 秒才算有效行为。虽然不太精确,但成本很低,效果提升很明显。

3. 个性化推荐机制的实现思路

3.1 行为采集与权重定义

推荐引擎的第一步,是把原始行为转换成可计算的数值。我用的权重方案很简单:

  • 有效浏览:1 分
  • 收藏:2 分
  • 加入行程:3 分
  • 下单购买:5 分

为什么要这样设?因为不同行为代表的心理热度完全不同。用户可能随便浏览十个景点,但只会收藏两三个,真正愿意下单的更少。如果一个景点能触发用户下单,说明它和用户偏好的匹配度远高于“被划到了一眼”。这里没有标准答案,你可以根据自己的业务场景调整,但切记要保持“下单大于收藏、收藏大于浏览”的直觉逻辑。

行为记录不需要实时写入推荐分。我一般是先写user_behavior表,然后通过一个定时任务或者延迟队列,每隔几分钟把新增行为批量折算进用户偏好缓存里。如果每次请求推荐接口都实时算原始行为,数据库压力会非常大。

3.2 基于偏好标签的用户画像打分

用户画像打分的过程,其实就是“把用户历史行为映射到景点标签,再做加权求和”。我举一个具体的计算例子。

假设用户最近有 4 条行为记录:

  • 浏览了景点 A,A 的标签是“亲子”、“公园”,行为权重 1。
  • 收藏了景点 B,B 的标签是“亲子”、“动物园”,行为权重 2。
  • 浏览了景点 C,C 的标签是“历史”、“博物馆”,行为权重 1。
  • 下单了景点 D,D 的标签是“历史”、“古建筑”,行为权重 5。

那么用户对“亲子”标签的偏好分 = 1 + 2 = 3;对“历史”标签的偏好分 = 1 + 5 = 6;对“公园”、“动物园”、“博物馆”、“古建筑”这些标签,也分别按行为权重累加。这样得到的就是一个「标签 -> 偏好分」的 Map。

接下来给候选景点打分,逻辑是:景点 E 的标签是“历史”、“古建筑”,景点 E 的推荐分 = 6 + 6 = 12。景点 F 的标签是“亲子”、“动物园”,推荐分 = 3 + 2 = 5。最后按分数排序,历史类景点排在前面。

真实现实中,还要对偏好分做归一化,比如把最高分压到 1.0,避免某个行为特别高频的用户把所有推荐结果都锁死在同一个标签上。归一化公式不复杂:finalScore = score / maxScore。如果你看到推荐结果里全是同类景点,多半是漏了这一步。

3.3 基于景点的物品相似度推荐

除了按用户偏好打分,我还喜欢加一路“基于物品相似度”的候选来源。原理是:用户喜欢景点 X,系统就去计算哪个景点和 X 的标签最像,然后把相似景点也推荐给用户。这在推荐算法里叫 Item-based Collaborative Filtering。

计算相似度最简单的方式是余弦相似度。每个景点可以表示成一个标签向量,比如景点 X 的向量是[亲子:1, 公园:1, 摄影:1],景点 Y 的向量是[亲子:1, 公园:1, 徒步:1],两者重合的标签越多、向量夹角越小,相似度越高。如果直接用 SQL 算太复杂,可以做成内存计算:每次景点标签变更时,预计算好一份“相似景点 Top 10”列表,存到 Redis 里,用户请求时直接取。

为什么不用更复杂的 User-based 协同过滤?因为这个项目的用户规模没有那么理想,“用户 A 和用户 B 相似,所以把 B 看过的推荐给 A”这种逻辑在冷启动阶段很难生效,而且实时计算用户间相似矩阵的成本很高。基于物品相似度更稳,解释起来也更直观。

3.4 冷启动与推荐结果兜底

新用户没有历史行为,新景点没有用户反馈,这就是推荐系统里的冷启动问题。我的处理策略分三层:

  • 新用户注册时,让用户主动勾选兴趣标签,比如亲子、历史、美食、徒步。这个动作能直接生成初始画像,跳过“攒行为”的过程。
  • 用户没有做任何选择时,推荐全局热门景点。热门不是单纯按浏览量排序,而是按“浏览 + 收藏 + 下单”加权后的热度排序,避免大量无效点击上榜。
  • 新景点上线后,如果没有任何行为数据,先按标签匹配进推荐流,同时给一个基础加权分,让运营可以手动“置顶推广”。

兜底策略还要考虑一个细节:用户已经下单或者明确不感兴趣的景点,不应该再出现在推荐列表里。我每次推荐查询都会带上一个EXCLUDE_SCENIC_IDS参数,从用户已购记录里取出来,在 SQL 或内存里直接过滤掉。有些系统不做这步,用户刚买完东西,首页还在推同一个景点,体验非常差。

4. 基于SpringBoot的核心接口与业务实现

4.1 推荐接口的设计与落地

推荐接口我习惯设计成 GET 请求,返回一个带推荐理由的列表。请求参数包括用户 ID、城市、条数上限,比如:

GET /api/recommend/scenic?userId=1&city=杭州&limit=10

响应结构大致是这样的:

{ "code": 0, "data": [ { "scenicId": 101, "name": "西湖", "score": 98.5, "reason": "近30天你有3次历史类景点浏览记录" } ], "total": 10 }

推荐理由这个东西很容易被忽略,但对用户体验影响很大。用户看到一个“为什么推荐给我”的说明,会觉得系统是懂自己的,而不是一个冰冷算法扔出来的结果。实现时,我是在推荐打分过程中把“命中的标签”保留下来,然后根据权重最高的标签生成一句模板文案。

核心 Service 的逻辑可以这样理解:

public List<ScenicRecommendVO> recommend(Long userId, String city, int limit) { Map<String, Double> tagPref = buildUserTagPreference(userId); if (tagPref.isEmpty()) { return listHotScenic(city, limit); } List<Scenic> candidates = scenicMapper.selectByCity(city); List<ScenicRecommendVO> result = new ArrayList<>(); for (Scenic scenic : candidates) { double score = calcRecommendScore(scenic, tagPref); ScenicRecommendVO vo = ScenicRecommendVO.from(scenic); vo.setScore(score); vo.setReason(buildReason(scenic, tagPref)); result.add(vo); } result.sort((a, b) -> Double.compare(b.getScore(), a.getScore())); return result.stream().limit(limit).collect(Collectors.toList()); }

这里有一个在实际开发中很重要的点:不要把整个推荐逻辑一股脑塞进 Controller 或 Mapper。上面的buildUserTagPreference、calcRecommendScore、buildReason都是独立的私有方法,方便单元测试。我后来把打分和理由生成拆成了独立策略类,接其他推荐算法时只需要替换实现。

4.2 行程规划:把推荐结果排成一张能执行的时间表

推荐出景点之后,用户往往还有“帮我安排一下路线”的需求。行程规划模块的核心,是把选中的多个景点分配到某一天,尽量让路线顺路、时间不漏。

我采用的方式是贪心排序:先按景点经纬度计算用户指定出发点到各个景点的距离,把距离近的排在前面,再按“游玩时长 + 交通时长”累加,单日总时长控制在 8 小时左右。比如上午 9 点出发,景点 1 游玩 2 小时,交通 0.5 小时,下一个景点 11 点半开始,中午预留吃饭时间,下午接着排。如果当天排不下,就把剩余景点顺延到第二天。

SpringBoot 实现时,行程单itinerary和行程明细itinerary_item是一对多关系。生成行程是一个事务性操作:先插入主单,再插入多个明细。如果某个景点已经下架或者库存不足,整个生成事务回滚。我遇到过把@Transactional加在私有方法上导致事务失效的情况,所以这里要提醒一下:Spring 的事务代理是基于接口或类代理的,自调用不生效。事务注解要加在 public 方法上,并且通过注入的 Service Bean 调用。

4.3 门票预订与库存并发控制

预订功能最考验细节。用户选好景点后,要给该景点对应的门票库存扣减一条。表设计时我在scenic表里直接维护了一个stock字段。扣减时绝对不能先查库存再在 Java 代码里判断,因为并发请求下两个线程可能同时读到相同的库存数,然后一起执行减一,导致超卖。

一个简单的正确做法是使用数据库的条件更新:

UPDATE scenic SET stock = stock - 1 WHERE id = ? AND stock > 0;

如果后端收到的影响行数是 1,说明扣减成功;影响行数是 0,说明库存已经为 0,要返回“已售罄”的提示。这个方案在并发量不是特别高的时候完全够用。

如果预期并发很大,可以把库存先放到 Redis 里,用 Lua 脚本保证“判断库存 + 扣减库存”原子执行。下单成功后,再把订单写入 MySQL,通过异步消息或者定时任务做最终一致性。这个项目里我出于简单考虑,用了数据库条件更新加 Redis 预扣减的组合,逻辑简单也足够稳定。

4.4 后台管理与接口鉴权

推荐系统不只是给用户看前端页面的,管理员还需要维护景点、标签和订单。后台接口和用户端接口要隔离,最直接的做法是分别放在/admin/**和/api/**路径下,再用拦截器做权限控制。

我用 SpringBoot 的HandlerInterceptor实现了 Token 校验。用户登录成功后,服务端生成一个 UUID 作为 Token,存到 Redis 里并设置过期时间。前端每次请求带上Authorization头,拦截器里查一下 Redis 是否存在这个 Token。管理端则额外校验用户角色,只有ADMIN角色能访问/admin/**。这个方案比直接用 Spring Security 简单,也更容易理解。

要注意的是,Token 过期时间不要太长,我通常设置成 7 天。管理后台操作要记录操作日志,至少包括操作人、操作时间、操作内容。这些日志在排查推荐位数据异常时特别有用。

5. 实操过程与高频问题排查

5.1 从零搭建SpringBoot项目

环境准备上,我用的是 JDK 17、Maven 3.8、MySQL 8.0、Redis 6.2。创建 SpringBoot 项目时,官方推荐的 Spring Initializr 已经很好用了。核心依赖只有这几个:

  • spring-boot-starter-web:提供 Web 能力与内嵌 Tomcat。
  • spring-boot-starter-validation:做参数校验。
  • mybatis-plus-spring-boot3-starter:数据持久层。
  • spring-boot-starter-data-redis:缓存与 Token 存储。
  • mysql-connector-j:MySQL 驱动。

依赖版本最好选一个稳定的正式版,不要一味追求最新。最近很多项目启动报错,都是因为把 SpringBoot 升级到了太高版本,导致第三方的 starter 还没有适配,启动时要么 ClassNotFoundException,要么 Bean 循环依赖。做项目讲究“能跑优先”,等业务稳定后再考虑升级。

5.2 经典报错:JPA懒加载导致JSON序列化失败

如果你用 Spring Data JPA 而不是 MyBatis,会经常遇到LazyInitializationException。原因是实体里的关联关系设置成fetch = FetchType.LAZY后,在事务结束、Session 关闭的情况下,Jackson 序列化时还会尝试加载关联对象,结果底层连接已经不可用。

这个报错我见的太多了。解决方案有三种:

  • 在实体关联字段上加@JsonIgnore,让序列化忽略关联对象。
  • 使用 DTO 对象,不直接返回实体。
  • 查询时用@EntityGraph把需要关联的字段一次性查出来。

我个人最推荐第二种:DTO。它不仅能解决懒加载问题,还能避免把敏感字段(比如用户密码)一起返回前端。如果你用的是 MyBatis 或者 MyBatis-Plus,可以配置合理的 resultMap 或者用扩展类,思路是一样的。

5.3 推荐结果不准、重复怎么办

推荐结果不准,先不要急着换算法,八成是数据问题。我排查的顺序是:

  1. 用户行为是否成功写入?很多“不准”其实是行为记录丢失导致画像为空。
  2. 标签是否合理?如果 A 景点同时打上“亲子”和“夜店”,画像自然混乱。
  3. 权重是否生效?可以打印出用户标签偏好 Map,看看历史分数是否和自己手工算的一致。
  4. 是否有缓存?如果 Redis 里缓存了旧推荐结果,新行为可能需要一段时间才生效。

推荐结果重复的问题,多半是没有做联合去重。我的做法是,最终推荐列表先放“用户偏好推荐”,再去掉用户已经下单、收藏过的景点,最后再按分数排序。如果还有重复,检查是不是同一个景点的多个门票规格被当成了多条记录。

5.4 性能优化:接口从800ms降到120ms的小改动

推荐接口早期上线时,响应时间经常超过 800ms。为了低于 300ms,我做了一次性能优化。先定位瓶颈:发现每次推荐都要查用户行为表、查候选景点、再查每个景点的标签,SQL 执行了几十条,是典型的 N+1 问题。

优化方式是三层:

  • 第一层,用户行为查询改成只查最近 30 天数据,避免扫描历史全表。
  • 第二层,景点标签批量查出后放到内存 Map,不再循环查单条。
  • 第三层,推荐结果按城市和用户标签维度缓存到 Redis,有效期 5 分钟。用户请求时直接命中缓存,只有缓存过期才重新计算。

改完之后,接口平均耗时降到 120ms 左右。这里想强调的是,性能优化不是炫技,而是先把 SQL 和对象模型调顺,再引入缓存。很多项目一上来就加 Redis,结果缓存穿透、雪崩问题一大堆,得不偿失。

6. 常见问题速查表与我的经验总结

6.1 高频问题速查表

问题常见原因解决方式
推荐接口返回很慢循环查数据库导致 N+1批量查询,减少循环数据库访问
用户看到重复景点已购景点未过滤推荐查询前排除已购和已收藏 ID
收藏功能一直报 500懒加载序列化异常返回 DTO,不返回实体
下单库存超卖先查库存再扣减使用UPDATE ... WHERE stock > 0条件更新
中文乱码数据库连接未指定 UTF-8在 JDBC URL 加characterEncoding=utf8
新用户没有推荐内容冷启动策略缺失注册时选择兴趣标签或返回热门榜
Token 校验失效Redis 过期策略配置问题设置合理的过期时间并刷新 Token
行程生成一半失败事务边界设置错误确保事务注解在 public Service 方法上

6.2 项目落地时容易被忽略的5个细节

第一个细节是接口参数校验。推荐接口的limit如果传 10000,你的排序和内存计算都可能被打满。我会用@Min、@Max注解限制参数范围,同时在 Service 里做二次防御。

第二个细节是日志。推荐算法是“黑盒”,如果没有日志,出了问题基本无从查起。我习惯在关键节点打印结构化日志,比如用户 ID、候选景点数量、最终推荐列表前 5 个 ID。这样用户反馈“推荐不准”时,可以直接追到当时的输入输出。

第三个细节是数据库索引。user_behavior表我只建了两个索引,一个是user_id + create_time,一个是scenic_id。这个字段组合能覆盖 90% 的查询场景,不要给每个字段都建索引,否则写入性能会明显下降。

第四个细节是配置文件的环境隔离。开发环境、测试环境、生产环境的数据库地址和 Redis 地址是不同的。用application-dev.yml、application-prod.yml分开管理,启动时通过spring.profiles.active指定,能避免很多线上事故。

第五个细节是统一异常处理。我用@RestControllerAdvice做了一个全局异常处理器,把参数异常、业务异常、系统异常分别归类返回。前端只需要处理一个统一结构的错误 JSON,不用每个接口写 try-catch。

6.3 做完这个项目之后,我想说几句实话

这个项目让我最深的体会是,推荐系统不是越复杂越好。很多架构文章会讲 TensorFlow、向量数据库,但实际做业务系统时,可能一张user_behavior表加一套标签打分规则就能解决大部分问题。与其盲目追新,不如先把手里的行为数据和标签质量打磨好。

另一个体会是,SpringBoot 项目能不能做得顺,很大程度上取决于数据模型。表结构设计清楚,关联关系明朗,后面写接口就是一层层翻译业务;表结构混乱,每写一个功能都像在补窟窿。如果你正在准备类似的项目,我建议把六成时间花在需求和表设计上,写代码反而是最快的一部分。

最后再分享一个小技巧:推荐接口上线后,一定要留一个“人工干预”的口子。运营可以手动调整某个景点的基础权重、置顶或者下线。再智能的系统也需要业务兜底,这个口子看着不起眼,但能帮你和运营同学都省下大量沟通成本。做技术不是只会写代码,能把业务顺畅地跑起来,才是真正有价值的事。

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

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

立即咨询