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

发布时间:2026/10/21 23:58:56
基于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() { ListScenic 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(ListInteger vectorA, ListInteger 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(); ListRecommendVO list recommendService.recommendByUser(userId, limit); return Result.success(list); }recommendByUser的内部逻辑对应之前讲的三个层级策略。代码里的顺序大致是查询用户偏好标签 Top3。用这些标签去召回候选景点按标签匹配数降序排序。在候选中剔除用户已经下单或明确不感兴趣的景点。如果候选不足 5 个用热门榜补位。最终结果写入 Redis 缓存键为recommend:user:{userId}过期时间 30 分钟。这里要强调一下过滤功能。如果用户已经买过某景点的票还持续收到该景点的推荐体验会很糟糕。所以在推荐 SQL 里我用了一个NOT IN子查询把订单表里存在记录的内容排除掉。有的项目会把推荐结果直接输出成 JSON 数组给前端有的则会要求返回分页结构。我建议用传统 Page 结构这样前端扩展性更强换成小程序或 App 时改起来也方便。5. 部署、演示与常见问题5.1 开发环境一键启动配置这套系统的开发环境建议这么配JDK 1.8 或 11Maven 3.6MySQL 8.0Redis 6.xNode.js 14。项目启动前需要初始化数据库我习惯把初始化脚本放在项目根目录的sql文件夹下里面包含建表语句和演示数据。启动顺序是先启动 Redis再启动 MySQL然后启动后端。后端application.yml里必须改两个地方spring: datasource: url: jdbc:mysql://localhost:3306/travel_rec?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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在行程规划里加入驾车时间和步行时间做一个真正能用的“智能行程助手”。这两个方向都踩在推荐系统的核心业务上延伸出来都是很好的升级点。我个人在开发这类系统时最大的感受是推荐系统本身并不难难的是把用户行为、数据存储、算法模型、后端接口串成一个闭环。如果你也在写类似项目先别急着堆功能把“用户产生行为 → 行为更新偏好 → 偏好影响推荐 → 推荐反馈给用户”这条链路跑通后面的功能都是围绕这条主线的自然延伸。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询