“Spring Boot旅游景点推荐管理系统”这类标题,在各大毕设和培训机构项目库里基本是标配了。但你真把它当成一个“随便写写增删改查的课设”来糊弄,那就踩坑了。这个名字背后实际上是一整套需求:景点的基础信息管理、用户侧的检索收藏、标签体系的搭建,以及一个能让用户“逛得下去”的推荐逻辑。单纯把景点列表拉出来展示,那不叫推荐系统,那叫“景点后台管理”。真正到面试官或答辩老师问起“你的推荐是怎么算的”,你得能讲清楚算法逻辑、数据流向和冷启动处理,这茬才算真正过关。
这篇文章我直接把自己做这类项目时沉淀下来的方案、代码、参数选择和踩坑点全部写出来,按从整体设计到细节实现的顺序走,你照着推演一遍,比自己盲写一个月强得多。
1. 项目梳理与技术选型的取舍
1.1 项目核心需求到底有哪些
先别急着写代码。你把“旅游景点推荐管理系统”这十个字拆开看,核心其实四个字:景点、推荐。前面的“旅游”和“系统”都是场景限定。真正要考虑的是下面几条:
- 景点信息维护:管理员能添加、编辑、上下架景点,景点通常包含名称、所在城市、门票价格、开放时间、简介、图片、标签等字段。
- 游客浏览路径:普通用户注册登录后,可以浏览景点列表、按城市或关键词搜索、查看景点详情、收藏感兴趣的景点。
- 推荐逻辑:这是项目区别于普通管理系统的关键,必须有实际算法参与计算,而不是“查个列表按时间排序”糊弄过去。
- 简单统计:管理员后台能看到热门景点排名、用户收藏趋势等,这属于加分项,很多人忽略但答辩老师很吃这套。
一句话总结:用户数据 + 景点数据 + 行为数据,是推荐系统的三大输入,缺一不可。所以你的数据库设计从一开始就要为“行为数据”留表,别像有些人只看景点和用户两张表就开写。
1.2 为什么选Spring Boot作为底座
这类项目选Spring Boot几乎是最省力的路线,没有之一。Spring Boot把配置自动化和依赖管理做到位之后,整个后端结构非常清晰:Controller接收请求、Service处理业务、Mapper操作数据库,标准的三层结构,配合Maven管理依赖,一个人从零搭项目到完成核心功能,正常节奏两到三周能跑通。
说几个选Spring Boot而不是其他框架的实际理由:
- 自动装配机制省去大量XML配置。以前用SSM框架要手动配数据源、配事务、配MyBatis工厂,Spring Boot用
spring-boot-starter全家桶就能把环境拉起来。 - 内嵌Tomcat,打包成JAR直接跑,部署非常方便,这对毕设演示或自己玩耍极其友好。
- 生态里整合MyBatis、JPA、Redis、定时任务都是开箱即用,后面想加功能不用推翻重来。
但注意,Spring Boot有一个很坑的点就是版本选择。现在很多教程直接让你上Spring Boot 3.x,看起来是“选新不选旧”,但你如果是JDK 8环境,还得回头改Java版本,非常折腾。我建议做成毕设或学习项目就稳稳用Spring Boot 2.7.x + JDK 8 + MyBatis + MySQL 5.7或8.0。这个组合踩坑的人最多,网上资料也最全,出了诡异问题大概率有现成解法。
1.3 前端方案选Vue前后端分离还是模板引擎
这是很多初学者第一个纠结的点:“我用不用Vue?”我的建议很直接:如果时间紧、想尽快看到东西,用Spring Boot整合Thymeleaf,前端页面放在templates目录里,ModelAndView直接渲染,开发速度最快。但如果你想在简历上写“前后端分离经验”,那就老老实实把前端拆出来,用Vue 2或Vue 3 + Element UI,跑在8081端口,后端8080,通过接口联调。
前后端分离的好处不是“看起来高端”,而是写代码的时候你天然会关注接口设计——返回什么格式、错误码怎么定义、分页参数怎么传给前端。这些问题在后端面试时非常常考,项目里有真实实践的话,回答起来完全是加分项。
至于Vue打包好之后要不要放进Spring Boot的static目录里?如果项目做完想在服务器上单机部署演示,建议最后把这步做了。前端npm run build生成dist目录,拷贝到src/main/resources/static下,JAR包启动后直接访问80端口就能看到页面,省得在服务器上还要配Nginx或单独部署前端。
2. 系统设计与数据库模型
2.1 分包结构与整体设计思路
后端包结构我一般按下述方式来组织,清晰且符合日常工作习惯:
com.example.travel ├── controller // 接口层 ├── service // 业务逻辑层 │ └── impl ├── mapper // 数据访问层(MyBatis Mapper接口) ├── entity // 数据库实体类 ├── dto // 前端交互对象(VO) ├── config // 配置类(CORS,拦截器) ├── common // 统一返回结果、异常处理 └── recommend // 推荐算法相关类多说一句common包的必要性。统一返回结果类Result<T>几乎是项目标配,包含code、msg、data三个字段,前端统一按这个结构解析,能省很多事。加上全局异常处理器@RestControllerAdvice,后端就不会动不动把异常栈直接甩给前端,看起来很业余。
数据库设计上,我把核心表列出如下:
| 表名 | 核心字段 | 职责说明 |
|---|---|---|
| user | id, username, password, nickname, avatar, city | 用户基础信息 |
| scenic | id, name, city, price, open_time, description, cover, status, tag_ids | 景点主表 |
| tag | id, name | 标签字典表 |
| scenic_tag | id, scenic_id, tag_id | 景点-标签关联表 |
| favorite | id, user_id, scenic_id, create_time | 用户收藏记录 |
| rating | id, user_id, scenic_id, score, create_time | 用户评分记录(可选,能加就加上) |
| browse_log | id, user_id, scenic_id, count | 浏览行为聚合表 |
这里强调一下:推荐算法常用的“用户-物品”行为数据,在系统里主要由favorite和browse_log提供。很多人做毕设时只在表里设计了收藏,没存浏览行为,结果做协同过滤时发现用户的行为数据太稀疏,根本算不出来。我建议从一开始就加一个浏览日志表,用户每次点进景点详情就更新一次count,后面做推荐时的数据源就丰富很多。
2.2 景点标签体系怎么设计
标签是整个推荐系统的润滑剂。没有标签,基于内容的推荐就无从下手。我看到的很多毕设项目做了标签表,但只是给景点加了个tags字符串字段,用逗号分隔,这种做法在查询时确实方便,但做推荐时还得手动拆分字符串,非常别扭。
正确做法是拉一张关联表scenic_tag,体现“多对多”关系:一个景点可以有多个标签,一个标签也可以关联多个景点。推荐计算时,直接SQLjoin查出一个景点的标签ID集合,再和用户兴趣标签集合做集合运算即可。
标签内容怎么来?不用人工去编太多,初始数据够用就行,比如:历史古迹、自然风光、亲子游、美食打卡、户外徒步、红色旅游(如果命题敏感度要注意的话,普通项目里建议不要涉及这类容易带出争议方向的标签)、城市地标、主题乐园。管理员后台再加一个简单的标签管理功能,能增删改查就够了。
2.3 关于“热门景点”这个隐藏需求
你细心观察旅游类App,几乎都有“热门推荐”“本周TOP10”这种模块。放在我们项目里,这也属于推荐的一部分——冷启动兜底推荐。热门榜的计算逻辑可以很简单:根据收藏数加浏览数加权排序,比如:
score = favorite_count * 3 + browse_count * 1
为什么要加3倍权重?因为收藏是强意图行为,浏览只是泛兴趣,强行为应该比弱行为权重高。这里你用公式就能让答辩老师看出你对业务场景有思考,而不是一拍脑袋随便排序。
热门榜的实现方式也建议用定时任务,每10分钟或每小时算一次缓存到Redis或内存Map里,而不是每次用户请求时实时算,因为涉及两张表的聚合查询,实时算会让数据库压力变大。Spring Boot里用@Scheduled(cron = "0 0/10 * * * ?")就能搞定。
3. 推荐算法从0到1的实现
3.1 基于内容的推荐:最短路径
基于内容推荐的核心逻辑是“给用户推荐和他喜欢的景点相似的景点”。具体落到这套系统里,就是把景点变成标签向量,然后判断两个景点的相似度。
假设目前系统里有三个标签:自然风光A、历史古迹B、亲子游C。景点X的标签是[自然风光, 历史古迹],用向量表示就是(1,1,0);景点Y的标签是[自然风光, 亲子游],向量就是(1,0,1)。两者相似度怎么算?最简单的就是余弦相似度:
cos = (X · Y) / (|X| * |Y|)
代入数值:分子 = 11 + 10 + 0*1 = 1,分母 = sqrt(2) * sqrt(2) = 2,所以相似度是0.5。这个值介于0到1之间,越接近1表示越相似。公式你不用自己推,但要在代码里写清楚,别光调库。
整个推荐流程走下来是这样:
- 取当前用户收藏数量最多的前N个景点,作为“兴趣种子集”。
- 对每个种子景点,在候选景点池里排除掉“用户已收藏/已浏览过”的景点。
- 用余弦相似度计算种子景点和候选景点的相似度。
- 累加相似度分数,按分数取Top10返回给前端。
这里的候选景点池不能是全部景点,否则每次计算性能很差。项目数据量小无所谓,但为了体现工程意识,建议按城市过滤一次,同城的候选池参与计算即可。旅游场景里,用户大概率对同城目的地更感兴趣,这也是合理的业务假设。
3.2 基于用户的协同过滤:另一种思路
UserCF的核心是“找到和你兴趣相似的用户,看看他们在看什么”。步骤是:
- 把用户收藏的景点集合取出来,用Jaccard相似度算用户之间的相似度:两个用户收藏集合的交集除以并集。
- 取Top5相似用户,把这些用户收藏过但当前用户没看过景点找出来。
- 按“有多少相似用户收藏过”进行排序,加权推荐。
这个方法比基于内容推荐更“个性化”,但问题在于数据稀疏。如果系统总共才几十个用户,大家收藏行为少,Jaccard算出来基本全是0。所以我在这个项目里有一个明确取舍:主推荐用基于内容的标签相似度算法,UserCF作为辅助推荐,只在新用户或热门榜上做补充。
这个取舍你在项目答辩时务必主动讲出来,而不是等老师问。主动暴露方案的边界和权衡,比被动辩解要专业得多。
3.3 代码结构怎么组织
我在recommend包下面放了三个类:
RecommendContext:存储用户兴趣标签、收藏集合、候选池等中间数据。ContentBasedRecommender:执行基于内容推荐的核心逻辑。CollaborativeFilterRecommender:执行UserCF逻辑。
Service层调用方并不需要关心具体算法,直接注入推荐器接口即可。这样设计的好处是算法可以替换,我的代码里Recommender接口定义了一个统一方法:
public interface Recommender { List<ScenicVO> recommend(Long userId, int topN); }这里注意一点:返回的推荐结果建议组装成ScenicVO,而不是直接返回Entity。VO里可能多出similarityScore、recommendReason这类展示字段,前端可以直接展示“因为你看过西湖,所以推荐你灵隐寺”这种文案,产品体验和答辩观感都会好很多。
3.4 冷启动问题怎么兜底
新用户没有收藏没有浏览,推荐算法完全算不了。此时直接返回热门景点列表是最稳妥的做法。我在实现时处理了三层:
- 第一层:如果用户收藏数为0,直接返回
hotList,即热门榜的前10条。 - 第二层:如果用户有收藏但候选池计算后推荐结果不足10条,用热门榜补位填充到10条。
- 第三层:如果用户对某类标签有明显的集中兴趣(比如收藏中的7个景点有6个带“自然风光”),在推荐结果中把这类景点的比例提到70%,这算是一点简单粗糙的“个性化干预”。
冷启动你不是写死成“热门兜底”就完事,三个层次能让评委看出你对真实推荐系统场景是有理解的。
4. 核心模块设计与实操记录
4.1 用MyBatis还是MyBatis-Plus
我看到热搜里有一串 “springboot+mybatis” 相关的词,说明这个组合确实是主流。但我要给你建议:如果用MyBatis-Plus,能少写大量CRUD的XML,因为它内置了通用Mapper和分页插件。单表CRUD用BaseMapper接口直接搞定,自己只需要在XML里写复杂的多表join和统计查询。
但你同时也得注意MyBatis-Plus有几个容易踩的坑:
- 逻辑删除配置。如果你给景点表加了
deleted字段,得用@TableLogic注解,否则删除后数据还在但查询却查不出来,很诡异。 - 分页插件要单独配置
MybatisPlusInterceptor,不配置Page对象就失效,查出来是全量数据,这个问题当年坑了我半小时。 - 多表关联查询时,要自己写XML并用
resultMap做映射关系,否则字段对不上。
4.2 推荐计算中的SQL怎么写
基于内容的推荐第一步要“取用户最感兴趣的标签集合”,这一步用SQL就能完成:
SELECT t.id, t.name, COUNT(*) AS interest_count FROM favorite f JOIN scenic_tag st ON f.scenic_id = st.scenic_id JOIN tag t ON st.tag_id = t.id WHERE f.user_id = #{userId} GROUP BY t.id, t.name ORDER BY interest_count DESC LIMIT 5这条SQL的作用是把用户收藏景点所涉及的标签按频次排序,作为用户兴趣标签。要注意JOIN的顺序和别名,别写错了。候选池的SQL我放在了Service里用LambdaQueryWrapper拼接,目的是把城市过滤和已收藏过滤做到动态SQL里,简化XML。
4.3 景点图片上传处理要点
景点管理里图片上传是必做的,很多新手直接存Base64到数据库。我不建议这么做,数据库会迅速膨胀,而且查询传输效率差。正确的常规做法是:文件上传到服务器某个固定目录,数据库只存相对路径。Nginx或虚拟路径映射是指向那个目录的。
Spring Boot里设置静态资源映射也比较简单,搞一个配置类:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceHandler("file:" + uploadPath + "/"); } }这样前端拿到/upload/xxx.jpg就能直接访问到图片,不用把文件提交给后端再转字节流返回。
注意上传文件大小限制。Spring Boot默认最大上传文件是1MB,很多图片一传就报错。需要在application.yml里调:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB4.4 接口鉴权别做得太重
毕设项目做登录鉴权,我强烈建议用JWT而不是搞Session。理由有两个:前后端分离场景下Session跨域比较麻烦;JWT无状态,接口测试也更方便。
整体流程通用套路如下:
- 用户登录成功后,后端生成一个JWT token(把userId和username放进去),设置过期时间比如7天。
- 前端把token存在localStorage,每个请求在Header里带上
Authorization: Bearer <token>。 - 后端写一个拦截器,放行登录注册接口,其余接口校验token,有效则把userId放进请求上下文中。
这里有一个点值得提醒:JWT的密钥要放到配置文件里,不要写死在代码里;过期时间设置别太短,否则用户逛着逛着就掉线,演示的时候非常尴尬。
4.5 用户行为数据采集
前面已经说了browse_log这张表很重要,现在说采集逻辑。用户在打开景点详情时会调用一个POST /api/scenic/{id}/view接口,这个接口做两件事:
- 当天的浏览记录如果存在则count加1,不存在则插入一条新记录,count为1。
- 把该景点ID放入一个最近浏览的队列里,方便后续“猜你喜欢”排除已浏览项。
为了避免每次请求都去数据库查一遍是否存在记录,我在这个接口上做了一层本地缓存,用一个Map<userId, Set<scenicId>>缓存用户已浏览的景点,定时任务每小时清理一次。这个做法属于轻量级的“半持久化”,面试讲出来比单纯查库要有亮点。
5. 常见问题排查与避坑技巧
5.1 MyBatis查询结果为空但数据库有数据
这种情况十有八九是字段映射对不上。MySQL的字段是下划线命名如open_time,Java实体是驼峰命名如openTime,如果没有开启驼峰映射,查出来就是null。解决办法:
mybatis: configuration: map-underscore-to-camel-case: true如果开了还是不行,那大概率是resultMap里没对应上,或者SQL里select的列名写错了。
5.2 前后端联调一直报跨域错误
前端在8081,后端8080,浏览器会拦截跨域请求。后端配置一个CORS过滤器是最快的:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }但注意,如果你用了Spring Security或自定义拦截器,拦截器对预检请求OPTIONS也可能拦截掉,导致跨域配置失效。这时候要在拦截器里对OPTIONS请求直接放行并返回200,这是很多新手卡住的地方。
5.3 端口被占用,启动直接报错
如果IDEA启动Spring Boot时报端口被占用,最简单的排查方法是:
lsof -i :8080 kill -9 <PID>Windows的话用:
netstat -ano | findstr 8080 taskkill /PID <PID> /F但这只是临时解决。我还见过有人改端口改错位置,写到application.properties里但文件根本没生效。Spring Boot的配置文件一定是application.properties或application.yml,放在src/main/resources下,确认一下命名和路径,不要拼错。
5.4 中文数据乱码
数据库和连接串两边都要设置utf8mb4。连接串上加characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai。还有一个细节:如果你的数据库表建的时候没指定字符集,即使连接串写了utf8,插入中文还是乱码,建表语句最好显式加一句DEFAULT CHARSET=utf8mb4。
5.5 Spring Boot自动装配原理简单捋一遍
面试问卷也常问“Spring Boot自动装配原理”,我帮你捋顺一句话版本:Spring Boot启动时,@SpringBootApplication里的@EnableAutoConfiguration会通过AutoConfigurationImportSelector读取所有spring.factories中配置的自动配置类,然后根据项目依赖和@ConditionalOnClass、@ConditionalOnMissingBean等条件判断是否生效。
对你项目最直观的影响就是:你引入了spring-boot-starter-web,SpringBoot会自动帮我们配置好DispatcherServlet、内嵌Tomcat、Jackson等;引入了mybatis-plus-boot-starter,就会自动配置SqlSessionFactory和Mapper扫描。如果这些自动配置出来的对象名字和你自己定义的冲突,就要特别注意别重复定义Bean,否则会启动报错。
6. 部署联调与项目演示
6.1 前端打包放进Spring Boot
如果你最后想一个JAR包全搞定,在前端项目根目录执行:
npm run build然后把生成的dist目录里所有文件,拷贝到src/main/resources/static/目录下。注意Vue Router如果用history模式,刷新页面时会找不到路由,建议改成hash模式,或者在Spring Boot里加一个Controller做前端路由转发。演示现场出这个问题非常难堪,我吃过这个亏。
6.2 参数配置管理
这类项目的配置不建议写死一个application.yml,而是分dev和prod两套。
application-dev.yml application-prod.yml application.yml(指定激活哪个)这样你在本地调试用dev,上线部署用prod,不用每次改端口或数据库账号。启动参数可以直接用:
mvn spring-boot:run -Dspring-boot.run.profiles=prod或者打成jar后:
java -jar xxx.jar --spring.profiles.active=prod项目用到的外部参数(数据库链接、Redis地址、上传路径)都应该放这里,改配置不需要重新编译代码,演示的时候调整环境那叫一个快。
6.3 项目演示时的数据准备
这一点有个很土的技巧,但非常实用:推荐算法要出效果,测试数据必须丰富。你演示时随便录3个用户、5个景点,推荐结果大概率很丑。我建议你提前准备一份SQL脚本,里面预置10个用户、50个景点、每个景点关联2~4个标签,再补上一些真实的收藏行为数据。这样演示时打开推荐页,效果才像回事。
数据来源的话,景点信息可以自己编,不用刻意追求真实,但标签分配要合理。收藏数据要有侧重,比如用户A重点关注自然风光,就要让他的收藏行为里这类标签景点占比高,推荐结果才准。提前把这份数据灌进去,比现场瞎点有信心得多。
6.4 说一嘴Spring Boot版本相关的坑
前面提到版本太高的问题,这里展开说。Spring Boot 3.x基于Jakarta EE,很多旧教程里引入的包名从javax.*变成了jakarta.*。如果你参考老代码,直接复制过来会是编译报错。另外Spring Boot 3.0要求JDK 17以上,如果你的开发机还没升到17,折腾环境的时间绝对超出预期。
作为稳妥方案:JDK 8 + Spring Boot 2.7系 + Maven 3.6以上,这套组合在绝大多数旧机器和新机器上都能顺畅跑,社区资料也是最多最完善的。别为了追求版本新而去踩没必要填的坑,写这项目的首要目标是跑通、能讲、能演示。
7. 推荐算法性能与扩展
7.1 计算性能要提前想
真实场景里推荐计算若要实时跑,数据量大时根本不行。我们项目规模小,可以每10分钟用定时任务把每个用户的推荐结果算好放进Redis缓存,用户发起请求直接读缓存即可。如果不想引入Redis,那就用HashMap+过期时间做内存缓存,代码量不大但效果立竿见影。
定时任务的写法用Spring Boot自带的@Scheduled就能解决,开启定时任务只需在启动类或配置类上加@EnableScheduling:
@Component public class RecommendTask { @Scheduled(cron = "0 0/10 * * * ?") public void refreshRecommendCache() { // 1. 遍历活跃用户 // 2. 逐个调用Recommender.recommend() // 3. 写入缓存 } }这里值得说清楚的是cron表达式含义。0 0/10 * * * ?表示每小时的0分、10分、20分……运行一次。定时任务的并发可以默认单线程,但如果用户量上来了,建议在方法上加上@Async交给线程池执行,避免阻塞主线程。
7.2 推荐结果总感觉不够聪明
这个项目写完之后,很多人的困惑是“为什么推荐的景点用户不感兴趣”。原因可能有两个:一是基于内容推荐天然有“信息茧房”问题,它只能推荐跟用户已有兴趣相似的景点,没法帮用户发现新类型;二是标签体系的粒度太粗,导致所有自然风光的景点相似度都差不多,没有区分度。
想改善,可以在策略上做个小改动:推荐结果里留20%的位置给“探索类”景点,即随机从用户未浏览的、热门度中等偏上的景点里抽取,放在推荐列表末尾。这样既不伤害整体体验,又能扩展用户的兴趣边界,项目答辩时讲出来也是加分项。
7.3 关于“Spring Boot整合Flink”
热搜词里有个“springboot整合flink”,我提一嘴只是提醒你别盲目上重型框架。旅游景点推荐管理系统这种数据量,单机推荐计算就绰绰有余,引入Flink这类流计算框架完全是杀鸡用牛刀,还会把项目复杂度拉到另一个量级,对你的毕设或线下项目是巨大负担。要把基础版本的推荐逻辑先跑通,真对大数据方向感兴趣,那是后续项目的事,别在这个标题下面强行嫁接。
8. 写在最后的实操经验
这个项目给我最大的教训就是:推荐系统的核心不在算法本身多高深,而在于你如何把行为数据采集完整、如何设计标签体系、如何兜底冷启动。这三件事做扎实了,哪怕你用的只是余弦相似度这种最基础的算法,整个系统的实用性和说服力也远远强于“看着很乱但各种高深技术乱炖”的demo。
如果你是一个人从零开始做,我给的建议是:先把景点管理和用户登录跑通,不要在推荐算法上一口气写太多;等基础功能稳定了,再把推荐逻辑迭代式地加进去,每加一步就自测一步。工程节奏比技术能力更决定项目成败。
最后再分享一个小技巧:强烈建议好好利用项目的README文件,把技术选型、模块设计、数据库ER图、推荐算法说明写清楚。你答辩时评委大概率会直接翻你的README,一份逻辑清晰的README,比你代码注释写得再花哨都有用得多。