news 2026/10/21 23:58:55

基于Spring Boot的旅游推荐系统设计:从行为采集到协同过滤落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Spring Boot的旅游推荐系统设计:从行为采集到协同过滤落地

1. 项目拆解与技术选型

1.1 先搞清楚这个系统要干什么

先把标题翻译成人话:这是一个基于 Spring Boot 的 Web 旅游推荐系统,编号 11757。这类系统在大学里出现的频率非常高,因为它同时覆盖了后端开发、数据库设计、推荐算法、前端交互几个大块,非常适合用来练手或者当作毕业设计。但说白了,它的核心就两件事:让用户能浏览、搜索、预订旅游相关的资源(景点、酒店、线路),然后系统能根据用户的行为数据,给每个用户推一批“他可能更喜欢”的内容。

我见过很多同类项目,最常见的毛病是功能堆了一堆,但推荐模块形同虚设,要么写死几条热门数据,要么干脆不实现。真正能称为“推荐系统”的项目,至少得具备这么几个能力:

  • 能够记录用户行为(浏览、收藏、下单、评分)。
  • 有一套推荐逻辑,不依赖写死的排序。
  • 推荐结果会随用户行为变化,而不是永远不变。
  • 后台能维护景点、线路、轮播图、订单等基础数据。

也就是说,用户端和管理端必须同时存在。用户端的核心是“找”和“订”,管理端的核心是“维护”和“分析”,推荐模块夹在中间,把两端串起来。本文后面所有内容都围绕这个定位展开。

1.2 技术选型,我为什么这么定

Spring Boot 本身只是个基础框架,真正决定项目质量的,是你选了哪些组件配合它。表 1 是这套系统最稳的组合。

层次选型核心理由
后端基础Spring Boot 2.7.x稳定,资料多,适合绝大多数环境
ORM 框架MyBatis-Plus单表操作零 SQL,复杂查询不受限制
数据库MySQL 8.0性价比最高,开发和部署都方便
缓存Redis做热门榜单、验证码、Session 共享
前端Vue 2 + Element UIVue 2 生态成熟,入门资料多,团队协作效率高
推荐算法基于内容的协同过滤不依赖用户量,前期也能跑出效果

有人可能会问,为什么不直接用 JPA 或者 MyBatis 原生?我个人的经验是:MyBatis-Plus 在单表 CRUD 的效率上远超原生 MyBatis,但遇到多表关联、动态 SQL 时又不会像 JPA 那样绕,属于折中的最优解。Spring Boot 2.7.x 配合 MyBatis-Plus 3.5.x 是当前最不容易踩坑的组合。

推荐算法的选择也很关键。基于物品的协同过滤(Item-CF)在旅游场景有个天然问题:如果两个景点从来没被同一个人消费过,它们之间就永远不会产生相似度,说白了就是稀疏性问题。所以我在这套系统里用的是“内容特征 + 行为加权”的混合策略,这个下一章详细展开。技术选型不需要追新,稳定、能落地、能说清楚原理,才是这类项目最需要的。

2. 数据库设计与核心表结构

2.1 基础表设计思路

数据库设计决定了后面所有功能的开发效率。这套系统里,我建议至少规划以下几张核心表,而不是把所有字段都塞进一张大表里。

用户表sys_user:保存账号、密码、昵称、头像、手机号。密码必须加密存储,推荐用 BCrypt,不要去 MD5 加盐,因为 Spring Security 自带 BCrypt 支持,安全级别也够。

景点表t_scenic:这是推荐系统最重要的内容表,字段越完整,推荐效果越好。除了名称、介绍、图片、地址、开放时间以外,一定要有“类型标签”字段,比如“自然风光”“历史文化”“亲子游乐”“网红打卡”,推荐模型全靠这个字段计算相似度。

线路表t_route:关联多个景点,形成行程方案。表里可以加一个 schedule 字段用 JSON 格式存储每天安排,这样做线路展示时很灵活。

订单表t_order:记录用户买的门票、酒店、线路等商品,状态字段至少包含待支付、已支付、已取消、已完成。这张表既是业务数据,也是推荐系统的重要数据来源——用户买过什么,比用户看过什么更能反映意图。

除了这些基础表,我还额外设计了收藏表t_favorite和打分表t_score。收藏表解决“用户喜欢什么”,打分表解决“用户有多喜欢”。推荐算法里这两张表的价值远高于浏览记录。

2.2 推荐模块的存储设计

很多人容易忽略一个点:推荐系统不是现算现跑,而是要把“用户对物品的偏好”以某种结构存下来,否则每次请求都全表扫描,数据库根本扛不住。我这里引入了两张推荐专用表。

第一张是用户偏好表t_user_pref,字段包括 user_id、tag_name、pref_score。pref_score 表示该用户对某个标签类型的偏好强度。每当用户产生行为,就给对应标签加分。举个例子,用户收藏了一个“自然风光”的景点,那么该用户对“自然风光”的偏好就会 +2;如果只是浏览,则 +0.5。

第二张是相似景点表t_scenic_sim,字段包括 scenic_id、similar_scenic_id、sim_score。这个表不需要每次实时算,可以用定时任务每天凌晨更新一次,白天推荐时只读数据就行。

用这种“预计算”思路,推荐的性能和效果都能兼顾。实时计算只用于用户刚产生行为后的那次“即时刷新”,其余时间走缓存和预计算表。

3. 推荐算法从零落地

3.1 用户行为数据的采集与权重

推荐算法的核心输入,是用户行为。一个用户在一个景点页面上多停留 10 秒,和这个用户直接下单买门票,表达的兴趣强度完全不同。我设计了一套行为权重体系,如表 2 所示。

行为类型权重值触发条件
浏览0.5进入详情页停留超过 5 秒
搜索点击0.8搜索结果中点击某景点
收藏2.0点击收藏并成功
评分3.0 * 分值打分时按实际分值加权
下单5.0支付成功后触发

为什么要区分这么细?因为如果所有行为权重一样,那么“手滑点了看一眼”和“掏钱购买”会被同等对待,推荐结果就会失真。下单行为的权重设为浏览的十倍,能让推荐结果快速响应用户的真实意图。

这套逻辑写成一个前端埋点接口,前端在页面滚动停留、点击、收藏、支付完成时,分别调用一次行为上报接口。后端拿到行为类型和景点 ID 后,异步更新t_user_pref表中的标签偏好分数。

3.2 相似度计算怎么写到代码里

基于内容特征计算景点相似度,最常用的办法是“标签构造向量”。假设我有五个标签:自然风光、历史文化、亲子游乐、网红打卡、美食购物。某个景点的特征向量就是[1, 0, 1, 0, 0],表示它属于自然风光和亲子游乐。两个景点的相似度就用余弦相似度计算:

sim = (A · B) / (|A| * |B|)

举个例子,景点 A 向量[1, 1, 0, 0, 0],景点 B 向量[1, 0, 1, 0, 0],分子是 1,分母是 sqrt(2) * sqrt(2) = 2,所以相似度是 0.5。这个值越高,表示两个景点在标签维度上越接近。

在代码里,我建议把向量存入 MySQL 的 JSON 字段或者单独一个标签表,然后在定时任务里两两计算相似度,过滤掉相似度低于 0.3 的结果,写入t_scenic_sim。代码结构大致是这样:

@Component @Slf4j public class ScenicSimilarityTask { @Resource private ScenicService scenicService; @Resource private ScenicSimService scenicSimService; @Scheduled(cron = "0 30 2 * * ?") // 每天凌晨 2:30 执行 public void computeSimilarity() { List<Scenic> allScenic = scenicService.list(); for (int i = 0; i < allScenic.size(); i++) { for (int j = i + 1; j < allScenic.size(); j++) { double score = cosineSimilarity( allScenic.get(i).getTagVector(), allScenic.get(j).getTagVector()); if (score >= 0.3) { // 双向写入相似关系 scenicSimService.save(new ScenicSim(i.getScenicId(), j.getScenicId(), score)); scenicSimService.save(new ScenicSim(j.getScenicId(), i.getScenicId(), score)); } } } } private double cosineSimilarity(List<Integer> vectorA, List<Integer> vectorB) { // 此处验证空间向量的实现 } }

这里tagVector在落地时可以直接从数据库查出来拼成列表,不需要真正引入分布式计算框架。对一个中小型旅游网站来说,景点数量通常几百到几千,双重循环加余弦公式足够用了。

3.3 冷启动与兜底策略

再好的推荐系统也有冷启动问题:新用户没有行为数据,新景点没有打分记录,这时候推什么?我采用三级兜底策略。

第一级,新用户直接推“热门榜”。热门榜不是简单按浏览量排序,而是用一个综合分数:浏览权重 + 收藏权重 + 订单权重,计算出一个热度值。因为游客在还没登录时点进来,系统给的是最热门的推荐,这个阶段没有个性化。

第二级,用户有一点行为数据后,就从行为中提取偏好标签,召回该类标签下相似度 TopN 的景点。比如用户收藏过“历史文化”类景点,系统就推送其他“历史文化”的景点,优先推相似度最高的。

第三级,用户行为数据足够多了(比如行为记录超过 20 条),就混合两种策略:50% 来自用户偏好标签召回,30% 来自相似景点扩展,20% 来自热门兜底。这样既保证个性化,又不会因为算法的“信息茧房”把所有内容都推成同一类。

冷启动这步我踩过不少坑,最有价值的一条经验是:过滤掉用户标签权重中占比过低的标签,比如用户只有一条行为记录,权重全加在“美食购物”上,不代表他就是美食爱好者。只有当某个标签的爱好分数超过全局平均偏好分数的两倍时,才判定为“强偏好”。

4. 核心模块的实现细节

4.1 登录校验与统一异常

Web 系统最基础也最重要的部分是登录校验。我用 Spring Security + JWT 做认证,没有用传统 Session 模式,原因是 JWT 天然适合前后端分离,服务端不需要存 Session,就更方便做横向扩展。用户登录成功后,后端返回一个 JWT token,前端存在本地,每次请求带在 Authorization 请求头里。

登录接口示例:

@PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { // 校验验证码 String code = redisUtil.get("captcha:" + dto.getUuid()); if (!dto.getCode().equalsIgnoreCase(code)) { return Result.error("验证码错误"); } // 校验账号密码 User user = userService.findByUsername(dto.getUsername()); if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.error("账号或密码错误"); } // 生成 token String token = JwtUtil.createToken(user.getId(), user.getRole()); return Result.success(token); }

这里有几个实操教训:BCrypt 校验的两个参数别传反了,第一参数是明文密码,第二参数是加密后的密文;验证码必须一次性使用,校验完就删掉;JWT 的过期时间不要设太长,我一般设为 2 小时,配合刷新 token 使用,用户体验和安全都能兼顾。

统一异常处理这块,我建议每个项目都建一个GlobalExceptionHandler,把业务异常和未知异常分别处理。否则一旦现场环境中某个接口抛了个空指针,前端拿到一堆英文堆栈提示,用户直接懵。统一处理成{code: 500, msg: "服务器开小差了,请稍后再试"},至少体验是合格的。

4.2 景点与线路模块

景点模块的难点不在列表页,而在详情页。详情页要包含景点介绍、图片集、评论列表、关联线路、相似景点推荐五块内容,如果是新手很容易写出 5 个查询接口,前端挨个调,白白浪费网络开销。我建议用 Controller 聚合一次返回,后端只提供一个接口,内部查 5 张表,组装成一个 DTO 返回。

@GetMapping("/detail/{id}") public Result getDetail(@PathVariable("id") Integer id) { ScenicDetailVO vo = new ScenicDetailVO(); vo.setScenic(scenicService.getById(id)); vo.setComments(commentService.listByScenic(id)); vo.setRoutes(routeService.listByScenic(id)); vo.setSimilar(scenicSimilarService.topSimilar(id, 5)); // 记录浏览行为,用于推荐 asyncBehaviorService.record(CurrentUser.getId(), id, "browse"); return Result.success(vo); }

代码里asyncBehaviorService.record是异步方法的调用,这样不会因为记录行为造成接口响应变慢,我这里使用的做法是直接把记录丢一个异步线程池处理。注意 Spring Boot 里开启@EnableAsync后,async 方法一定要放在独立 Bean 中,否则不生效,这是一个非常隐蔽的坑。

线路模块可以设计成“行程规划”形式:用户选定出发地、游玩天数、预算金额、偏好类型,系统根据这些条件自动组合一条线路。实现时我用的是贪心策略:先按用户偏好标签找出目标景点池,然后按地理位置距离逐个添加,保证每天的行程不会太赶。当然这不是最优解,但对一个演示项目来说,远比写死几条线路要显得专业。

4.3 订单与支付流程

订单模块要考虑的不只是 CRUD,还有一个很关键的点:库存。比如某个景点的票存量只有 100 张,如果两个用户同时下单,系统必须保证不超卖。我用 Redis 的DECR命令实现了库存扣减,比数据库行锁性能高得多。

流程是这样的:用户提交订单时,后端先查 Redis,看景点 ID 对应的库存 key 是否大于 0,然后用DECR减一。如果减出来是负数,说明超卖,立即回滚并提示“该景点今日已售罄”。支付成功的回调里再把订单状态从未支付改为已支付。

超卖的问题,本质上就是“先校验再扣减”两个步骤不是一个原子操作。用DECR一次性完成扣减,就是为了避免竞态。数据库里的实际库存可以设一个允许为负数的字段兜底,定时任务每小时把 Redis 的数据同步回 MySQL,两边保持一致。

订单这块的安全底线是:坚决不用前端传来的金额。金额一律从数据库的商品表读取,传给支付接口时也重新算一遍。我在其他项目里见过有人直接在订单表里取一个totalAmount字段,然后前端把价格改了,空子就是这么留下的。

4.4 推荐接口返回给前端

推荐接口的最终形态是这样一个接口:传入用户 ID(或者从 token 解析),返回一组景点或线路。

@GetMapping("/recommend") public Result recommend(@RequestParam(defaultValue = "10") Integer limit) { Integer userId = CurrentUser.getId(); List<RecommendVO> list = recommendService.recommendByUser(userId, limit); return Result.success(list); }

recommendByUser的内部逻辑,对应之前讲的三个层级策略。代码里的顺序大致是:

  1. 查询用户偏好标签 Top3。
  2. 用这些标签去召回候选景点,按标签匹配数降序排序。
  3. 在候选中剔除用户已经下单或明确不感兴趣的景点。
  4. 如果候选不足 5 个,用热门榜补位。
  5. 最终结果写入 Redis 缓存(键为recommend:user:{userId},过期时间 30 分钟)。

这里要强调一下过滤功能。如果用户已经买过某景点的票,还持续收到该景点的推荐,体验会很糟糕。所以在推荐 SQL 里我用了一个NOT IN子查询,把订单表里存在记录的内容排除掉。

有的项目会把推荐结果直接输出成 JSON 数组给前端,有的则会要求返回分页结构。我建议用传统 Page 结构,这样前端扩展性更强,换成小程序或 App 时改起来也方便。

5. 部署、演示与常见问题

5.1 开发环境一键启动配置

这套系统的开发环境建议这么配:

JDK 1.8 或 11,Maven 3.6+,MySQL 8.0,Redis 6.x,Node.js 14+。项目启动前需要初始化数据库,我习惯把初始化脚本放在项目根目录的sql文件夹下,里面包含建表语句和演示数据。启动顺序是先启动 Redis,再启动 MySQL,然后启动后端。

后端application.yml里必须改两个地方:

spring: datasource: url: jdbc:mysql://localhost:3306/travel_rec?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你本机的数据库密码 redis: host: localhost port: 6379

如果你用的是 Null 值,密码不填也行;但如果密码填错,启动时数据源初始化会报Access denied,这个要留意。启动后端后,访问本地的http://localhost:8080/api/doc.html就能看到接口文档,我集成的是基于 Knife4j 的自动接口文档,调试起来比 Postman 还要直观。

前端启动命令是:

npm install npm run serve

等看到App running at Local: http://localhost:8081的提示,就算成功了。注意如果后端端口是 8080,前端端口是 8081,需要在前端项目下找到.env.development配置文件,把VUE_APP_BASE_API设置成http://localhost:8080/api。这一步漏了,前端所有请求都会 404。

5.2 演示数据准备

很多初学者把项目跑起来后发现首页空空如也,就以为系统出 bug 了。其实是因为数据库里没有演示数据。我强烈建议初始化脚本里写入至少 20 个景点、5 条线路、3 个酒店、若干评论和订单数据。没有数据,推荐系统等于巧妇难为无米之炊。

演示账号也建议直接给两个:

角色账号密码
管理员adminadmin123
普通用户useruser123

管理员登录后跳到后台管理,能看到用户管理、景点管理、线路管理、订单管理、评论管理、轮播图管理。普通用户登录后跳到前台,能看到推荐首页、景点列表、线路规划、个人中心、我的订单。

演示数据有个小技巧:景点图片不要用本地文件,直接用外链图床,比如https://xxx.com/photo/1.jpg的形式。本地文件路径一旦部署到服务器,图片就会全挂,外链虽然受网络影响,但胜在省事,一个毕业设计完全够用。

5.3 常见问题与排查实录

我把开发这套系统时最常遇到的 6 个问题列在表 4 里,方便你遇到时直接对号入座。

症状可能原因解决方法
后端启动报Failed to configure a DataSource未配数据源或 MySQL 未启动检查application.yml和 MySQL 服务状态
前端登录后请求接口全 401JWT 过期或未传请求头检查 token 有效期,确认前端请求拦截器加了Authorization头
验证码一直报错Redis 没启动或 key 过期时间太短检查 Redis,确认验证码过期时间 ≥ 60 秒
列表页数据为空演示数据未导入执行sql/init.sql
中文乱码数据库字符集不是 utf8mb4建库时指定CHARACTER SET utf8mb4
推荐接口返回固定数据用户行为权重未更新检查行为上报接口是否被正确调用,Redis 缓存是否未失效

这里有几个排查技巧是文档里通常不会写的。第一个,遇到 401 先看浏览器的 Network 面板,确认请求头里有没有Authorization字段;第二个,验证码接口跟着报错,先redis-cli进命令行,keys *看看 key 是否还在,判断是不是过期了;第三个,推荐接口不更新,先删掉 Redis 里对应 key 再刷新,确认是算法问题还是缓存问题。

我在调试推荐模块时,最喜欢做的一件事是:用管理员账号给用户 user 手动插入 10 条“历史文化”标签的浏览记录,然后刷新推荐页,看看推荐结果是否偏向历史文化类。如果变了,说明行为采集链路通;如果没变,说明问题出现在 Redis 缓存或者行为异步写入那一步。

5.4 性能优化与后续扩展

最后聊几个性能优化方向。系统刚跑起来时 QPS 不高,单机完全扛得住,但不代表可以不考虑优化。我建议至少做三件事。

第一,首页景点列表接口是最大热点,单次可能查 10 张表。我会在 Redis 里缓存列表 JSON 数据,过期时间 10 分钟。别人问你怎么优化性能,这一步就是最直观的加分项。

第二,数据库索引不能省。t_order表的user_id、t_comment表的scenic_id、t_scenic_sim表的两列关联索引都要建。没有索引,数据量到几万条时接口延迟马上暴露。

第三,图片走 CDN。本地图片最大的问题不是存储,而是带宽。某次模拟测试时同一张图片被几千个用户同时请求,带宽直接被打满。换成 CDN 后这个问题瞬间消失。

这个项目如果要继续扩展,可以从两个方向入手。一是加入实时天气接口,根据目的地天气动态调整推荐排序——下雨天优先推室内场馆,晴天推自然景区,这能让推荐结果再上一个台阶。二是接入第三方地图 API,在行程规划里加入驾车时间和步行时间,做一个真正能用的“智能行程助手”。这两个方向都踩在推荐系统的核心业务上,延伸出来都是很好的升级点。

我个人在开发这类系统时最大的感受是:推荐系统本身并不难,难的是把用户行为、数据存储、算法模型、后端接口串成一个闭环。如果你也在写类似项目,先别急着堆功能,把“用户产生行为 → 行为更新偏好 → 偏好影响推荐 → 推荐反馈给用户”这条链路跑通,后面的功能都是围绕这条主线的自然延伸。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 23:57:41

PyInstaller打包勒索病毒样本分析:拆包、修复pyc头与反编译实战

拿到一个勒索病毒的Python打包样本&#xff0c;第一件事就是想办法把它还原成能看的源码。这个套路我已经跑过几轮了&#xff0c;涉及的工具链基本固定&#xff1a;pyinstxtractor负责拆包&#xff0c;uncompyle6负责反编译&#xff0c;中间还有一步很容易被忽略——修复pyc头。…

作者头像 李华
网站建设 2026/10/21 23:56:22

知识工作插件系统设计:架构取舍、生命周期与生态运营实战

1. 从“知识工作插件”这个命名说起&#xff1a;它到底想解决什么问题 第一次看到 knowledge-work-plugins 这个命名&#xff0c;我的直觉是&#xff1a;这不是一个普通的工具库&#xff0c;而是一套面向“知识工作者”的扩展机制。所谓知识工作者&#xff0c;说白了就是每天…

作者头像 李华
网站建设 2026/10/21 23:55:36

LiteLLM统一多模型调用:从碎片化适配到生产级网关实践

1. 从一个真实困境说起&#xff1a;多模型调用的碎片化之痛 如果你正在做AI应用开发&#xff0c;大概率遇到过这样的场景&#xff1a;项目初期用OpenAI的接口跑通了原型&#xff0c;效果不错&#xff0c;上线后老板说“能不能加个Claude做对比”&#xff0c;你花了两天适配Anth…

作者头像 李华
网站建设 2026/10/21 23:48:50

基于Zynq+AD9361的双通道SDR平台:架构、校准与场景应用实战

1. 先捋清楚自己到底在找什么&#xff1a;为什么最后盯着ZynqAD9361不放最近一直在评估一套基于 ZynqAD9361 的双通道SDR平台&#xff0c;也就是标题里那套 E310。参数表一眼看过去很干脆&#xff1a;2x2 MIMO、56MHz 瞬时带宽、软硬一体开放生态&#xff0c;应用场景直接指向频…

作者头像 李华
网站建设 2026/10/21 23:48:36

S7-1200与S120 PROFINET通讯实战:标准报文1配置与参数读写

简介&#xff1a;这份PDF文档面向工业自动化工程师、PLC编程人员及高校相关专业学习者&#xff0c;系统讲解如何通过PROFINET实现S7-1200 PLC与SINAMICS S120驱动器之间的实时通讯&#xff0c;解决控制器与分布式驱动设备组网配置、编程调试中的实际问题。资源包内含1个PDF文件…

作者头像 李华
网站建设 2026/10/21 23:45:58

Python环境容器化:Dockerfile构建与依赖管理实战指南

上午给A项目装好Python 3.8的环境&#xff0c;下午B项目要升3.11&#xff0c;一执行pip install&#xff0c;A项目的依赖直接崩掉——这种被环境问题反复折磨的场景&#xff0c;做过几年Python开发的基本都熟。docker容器配置Python环境的思路&#xff0c;就是从根上把这个问题…

作者头像 李华