每年三四月份,总有一批人被毕设选题逼到失眠。今天想拆的这个项目——基于SpringBoot的旅游景点推荐系统,编号14052——算得上毕设清单里的“常青树”。为什么说它常青?因为它难度适中,既有完整的业务闭环,又有一个可以往深里讲的推荐算法模块,从数据库到后端接口再到前端页面,一个人完全啃得动。这篇内容主要写给打算拿它做毕设的朋友:系统怎么拆、表怎么建、推荐怎么落地、代码怎么跑通、坑都在哪里,我按自己实际做过类似项目的经验一条条说清楚。
1. 项目价值与核心需求拆解
1.1 为什么旅游景点推荐系统能长期霸榜毕设选题
稍微翻一下历年的毕设题目清单就会发现,凡是带“推荐”“商城”“预约”“管理系统”字样的题目,永远是重复率最高的类型。旅游景点推荐系统就是“推荐”这个大分类下的典型代表。它的核心场景很好理解:游客面对一个陌生城市,打开App想看“哪里值得玩”,信息太多反而不知道选哪。系统要做的就是把合适的景点推给合适的人,本质上和电商商品推荐、短视频推荐是同一个逻辑,只是推荐的物品换成了景点。
这个题目特别适合当毕设,还有一个很现实的原因:它“可深可浅”。你不想卷算法,就做一个基于热度的推荐,按收藏数、评分、浏览量排序,也能交差;你想把亮点写进论文,就上协同过滤、用户画像、混合推荐策略,能讲的东西立刻多出一大截。毕设和工业项目的区别就在这里,评委看重的不是你的推荐准确率有多高,而是你能否把一条完整链路说清楚。
另外从管理学角度说,旅游行业的典型特征是“低频、高客单、强决策成本”。用户一年可能就出去旅游两三次,但每次决定去哪里都要付出大量搜索时间。推荐系统要缓解的正是这种决策焦虑。毕设的选题报告里如果能把这个痛点讲到位,你的开题答辩就已经赢了一半,因为评委一听就知道你不是为了凑题目硬选,而是真的理解了这个系统的存在价值。
1.2 系统功能边界:先画清楚要做多少事
我见过太多人做毕设,一上来就想做“旅游平台”,什么酒店预订、机票查询、攻略社区全往里塞,做着做着就崩了。推荐系统的前提是得有用户行为数据,这个系统的主角是“景点推荐”,其他所有功能都应该是为了生成推荐而服务,不能喧宾夺主。
拆解功能边界时,我会把它分成三条线:
- 用户端核心链路:注册登录 → 浏览景点列表 → 查看景点详情 → 收藏/评分/评论 → 获取个性化推荐列表。这套链路是闭环的关键,没有用户行为数据,推荐算法就是空转。
- 管理端基础维护:景点信息录入与编辑、景点上下架、评论审核、用户列表查看。不需要做复杂的权限角色,一个管理员账号就够,重点是让数据进来,让推荐有料可推。
- 推荐引擎模块:这是全项目的核心差异点,主要包括离线计算用户相似度、生成TopN推荐列表、处理新用户和新景点的冷启动。可以做成定时任务,也可以做成请求时实时计算,看你的数据量。
MVP思路非常重要。第一版先把用户端和管理端跑通,让用户能注册、能收藏、能评分,然后再往上加推荐算法。如果反过来,先折腾算法,再补基础功能,往往到最后连业务闭环都串不起来。记住,推荐算法再花哨,没有足够的数据支撑也只是个空壳子。
2. 技术选型思路:用最稳的组合保证顺利落地
2.1 SpringBoot到底帮你解决了什么问题
毕设项目的技术选型,首先要考虑的不是“酷不酷”,而是“你能不能在两三个月内写完,并且能讲明白”。SpringBoot能成为这个题目的事实标准,原因非常直白。
第一,它大幅降低了配置成本。早年间做SSM项目,spring-mvc.xml、spring-mybatis.xml、web.xml三件套能把人绕晕,稍微配错一个扫描路径,启动就报错,光排查配置都能耗掉一周。SpringBoot用自动装配把这些繁琐的模板化配置全部接管了,你只需在application.yml里写上数据源地址,其他的交给starter自动完成。我更愿意把它比喻成“预制菜”:食材和调料都给你配好了,你只需要关心怎么下锅,而不是从种菜开始。
第二,内嵌Tomcat让部署变得极其简单。以前部署SSH项目,你得单独装一个Tomcat,把war包丢进webapps目录,还要处理端口冲突、JVM参数、日志路径。SpringBoot直接打成可执行jar包,java -jar就完事了,毕设答辩现场换一台电脑也能快速跑起来,这一点对演示环节太重要了。
第三,生态足够成熟。SpringBoot几乎是Java企业级开发的事实标准,相关的教程、开源项目、面试题满天飞,你遇到任何一个报错,把异常信息复制到搜索框里,大概率能找到现成的答案。对于毕设选手来说,这也是一个隐形的时间成本优势。
2.2 数据库、缓存与前端选型怎么定
后端框架定了SpringBoot,周边的选型其实也基本顺理成章。
数据库选MySQL,没有任何悬念。它开源、免费、文档多,大学的数据库课程基本都教过,还是面试官最熟悉的数据库。版本推荐5.7或8.0,如果你用的是8.0,记得驱动名是com.mysql.cj.jdbc.Driver,连接URL要加serverTimezone参数,不然会报时区错误。ORM层用MyBatis-Plus就够了。它比原生MyBatis方便在不用写大量重复的CRUD SQL,BaseMapper自带增删改查,条件构造器也能满足大部分查询需求,学习成本非常低。
缓存选Redis。一是因为Redis是面试高频考点,写在简历上是加分项;二是它的数据结构很适合存用户行为数据,比如用ZSET存浏览记录,用STRING存热点景点的详情缓存。毕设项目不需要引入太复杂的缓存策略,做到“热点数据缓存 + 缓存过期”就足够了。
前端方面,我建议直接使用Vue3 + Element-Plus + Axios,做成前后端分离项目。如果你对前端不太熟,也可以直接用Thymeleaf配合Bootstrap做服务端渲染,少一套跨域问题,代码量也少。但这里有个权衡:前后端分离项目在答辩时展示性更强,接口调用和参数传递能讲得更清楚,也更贴近目前公司的开发模式。我的建议是,如果距离提交还有一个月以上,咬咬牙上Vue3加分;如果时间已经很紧,就用Thymeleaf保底,把精力留给后端和推荐算法。
3. 数据库建模与用户行为数据体系
3.1 核心表设计:七张表撑起整个系统
旅游景点推荐系统的表设计并不复杂,关键是要让“用户—景点—行为”这个三方关系清晰。按我的习惯,我会把它拆成七个核心表。
| 表名 | 主要字段 | 作用说明 |
|---|---|---|
| user | id, username, password, nickname, avatar, city, created_time | 用户基本信息,city可选,用于基于城市的兜底推荐 |
| scenic | id, name, city, category, description, address, cover_url, score, heat, status | 景点主体表,heat存热度值,status控制上下架 |
| rating | id, user_id, scenic_id, score, comment, created_time | 用户评分表,评分范围1-5,推荐算法的重要输入 |
| favorite | id, user_id, scenic_id, created_time | 收藏表,表示正向偏好,比评分数据更稀疏但更可信 |
| comment | id, user_id, scenic_id, content, created_time | 评论表,和管理端评论审核功能对应 |
| scenic_tag | id, scenic_id, tag | 景点标签表,一个景点多个标签,做基于内容推荐时用 |
| admin | id, username, password | 管理员表,管理端登录用 |
这七个表里,我最想强调三点。
第一,rating和favorite是两套不同的信号。评分表达的是明确的态度,3分可能就是不喜欢;收藏表达的是“想看、想去”的潜在意图,是比评分强得多的正向偏好。做推荐时,我会给收藏行为分配更高的权重,评分则按分数归一化后参与相似度计算。
第二,scenic表一定要有heat字段。这个字段可以手动维护,也可以在用户收藏景点时通过定时任务自动累加。它是热度推荐的基础,也是新用户冷启动时最重要的兜底数据来源。没有heat,你连一个最朴素的“大家都在看什么”功能都做不出来。
第三,景点标签独立成表,不要用逗号分隔的字符串存在scenic表里。独立表的好处是查询时可以走索引,而且后续扩展标签体系时不需要动主表结构。常见的标签如“自然风光”“历史古迹”“亲子乐园”“网红打卡地”,一个景点可以挂三到五个。
索引方面,user表的username加唯一索引,scenic表的city、category、heat各建一个普通索引,rating表和favorite表分别在user_id和scenic_id上建索引。这几个索引能覆盖绝大多数查询场景,太多了反而影响写入性能。
3.2 用户行为数据如何收集和使用
推荐系统最忌讳的是“有算法、没数据”。很多毕设作品把协同过滤写得洋洋洒洒,但实际系统里一个用户的评分数据都没有,导致推荐结果随机性极强。所以用户行为采集必须从第一天就设计好。
我建议在用户浏览景点详情、点击收藏、提交评分、发表评论时,统一向后端记录行为日志。不需要单独建一张大日志表,而是分别写入favorite和rating表,浏览行为写入Redis。
具体做法是:浏览行为用Redis的ZSET存储,key设计为user:history:{userId},member是景点ID,score是当前时间戳。这样既能记录用户最近浏览的景点列表,又能按时间排序,后续做“猜你喜欢”时,可以直接拿这些景点ID去匹配标签。
定期把Redis中的浏览记录同步到数据库的user_history表,然后清空Redis,避免数据丢失。这个“Redis当缓冲、数据库做持久化”的思路,听起来挺高级,实际实现只要一个定时任务就能搞定,答辩时可以重点讲。
有了行为数据,推荐引擎才有的可吃。评分数据用于协同过滤计算相似度,收藏和浏览数据用于构建用户标签画像,热度数据用于兜底推荐。三者结合,就是一个完整可讲的推荐逻辑。
4. 旅游景点推荐算法:从原理到代码落地
4.1 基于用户的协同过滤:先找志同道合的人
基于用户的协同过滤是推荐算法里最经典、也最适合毕设讲解的方案。它的思想可以用一句话概括:和你口味相似的用户喜欢的景点,你大概率也会喜欢。
具体分三步:
- 根据用户对景点的评分或收藏记录,计算用户之间的相似度;
- 找到与当前用户最相似的K个用户;
- 把这K个用户喜欢过、但当前用户没见过的景点,按得分排序推荐出来。
相似度计算最常用的是余弦相似度。我直接给出一个可以跑通的伪代码版本,建议你先理解思路,再根据自己的表结构调整。
/** * 计算两个用户基于评分的余弦相似度 * key为景点ID,value为评分(或行为权重) */ public double cosineSimilarity(Map<Long, Double> user1, Map<Long, Double> user2) { Set<Long> common = new HashSet<>(user1.keySet()); common.retainAll(user2.keySet()); if (common.isEmpty()) { return 0.0; } double dot = 0.0; for (Long scenicId : common) { dot += user1.get(scenicId) * user2.get(scenicId); } double norm1 = 0.0; for (Double value : user1.values()) { norm1 += value * value; } double norm2 = 0.0; for (Double value : user2.values()) { norm2 += value * value; } if (norm1 == 0.0 || norm2 == 0.0) { return 0.0; } return dot / (Math.sqrt(norm1) * Math.sqrt(norm2)); }有了相似度之后,就可以写UserCF的推荐方法。核心思路是遍历所有用户,找出与当前用户最像的前N个用户,然后把这N个用户有评分或收藏记录的景点聚合起来,排除当前用户已经看过的,按“相似度×行为权重”计算综合得分,取TopK返回。
需要注意的是,不同行为要设置不同权重。比如收藏算1.0,评分4分以上算0.8,评分3分左右算0.5,浏览过算0.1。权重的设置在代码里可以通过一个简单的Map来维护,不需要引入复杂的推荐框架,重点是让流程闭环。
4.2 基于内容的推荐:打标签才是正经事
基于用户的协同过滤有个明显弱点:冷启动。新用户注册进来,没有任何行为数据,找不到“志同道合”的人。这时候就要靠基于内容的推荐兜底。
基于内容的推荐核心也是三步:
- 提取用户的偏好特征,也就是用户历史行为涉及的景点标签集合;
- 计算每个候选景点与用户偏好特征的匹配度;
- 按匹配度排序推荐。
具体实现时,我建议把用户浏览过的景点标签作为偏好,比如用户看过“黄山”(自然风光、登山)、“宏村”(古村落、人文),那偏好集合就是{自然风光,登山,古村落,人文}。推荐时统计候选景点标签与偏好集合的重合度,重合越多,得分越高。
这段逻辑用Java实现一点不难,难的是数据的维护。所以我在写业务时,会把“用户偏好标签”固化成一个Service:
public Map<String, Integer> buildUserTagProfile(Long userId) { Map<String, Integer> tagCount = new HashMap<>(); // 1. 去favorite表查用户收藏的景点ID列表 // 2. 根据景点ID查scenic_tag表得到每个景点的标签 // 3. 遍历所有标签,统计出现次数 // 4. 返回TopN标签作为用户画像 return tagCount; }这个Service作为推荐引擎的辅助方法,既能支撑基于内容的推荐,又能在答辩时作为“用户画像构建”的亮点,非常划算。
4.3 冷启动问题与混合推荐策略
不管是什么推荐系统,冷启动都是绕不开的话题。毕设答辩时,只要你能主动说出冷启动的处理方案,评委一般就会认为你是真的思考过这个问题的。
我把冷启动分两大类:
- 新用户冷启动:用户没有任何行为数据。此时无法做协同过滤,也无法构建标签画像,最稳妥的方案是按“地域+热度”推荐。如果用户在注册时填写了所在城市,优先推荐该城市的热门景点;没填就推全国热度排名前10的景点。
- 新景点冷启动:景点刚上架,没有收藏和评分。此时要依靠景点标签做基于内容的匹配,把它推给偏好相似的老用户。比如新上架一个“古镇”标签的景点,系统会把它推荐给那些收藏过其他古镇的用户。
实际项目中,只靠单一算法容易出问题,我更推荐做一个简单的加权混合推荐。最终得分可以这样算:
推荐得分 = 0.5 × 用户协同过滤得分 + 0.3 × 内容匹配得分 + 0.2 × 热度得分当用户行为数据少于阈值时,协同过滤得分权重自动降低,内容匹配和热度权重提高;当行为数据很充足时,协同过滤权重提高。这个动态调整逻辑不用做得太复杂,一个if分支就能实现。但把这个公式写进论文里,推荐模块的完整度立刻就不一样了。
5. 关键功能实现与部署细节
5.1 从零初始化项目:依赖与配置
创建一个SpringBoot项目并不难,用IDEA自带的Spring Initializr就能完成。需要注意的是依赖选择,我通常建议勾选这几项:Spring Web、MySQL Driver、MyBatis Framework、Spring Data Redis、Validation,另外自己手动引入JWT相关依赖和MyBatis-Plus的starter,因为Initializr里不直接提供MyBatis-Plus。
application.yml是最核心的配置文件,我给出一个基础版本:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/travel_recommend?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmybatis-plus这一节里的map-underscore-to-camel-case要特别注意,它能把数据库的snake_case自动映射到Java的驼峰字段,比如scenic_id自动映射到scenicId,省去大量写resultMap的时间。log-impl设为StdOutImpl,可以在控制台看到每条SQL的执行情况,排查问题时非常有用。
项目结构上,我习惯按controller / service / mapper / entity / common分包。entity对应数据库表,mapper继承BaseMapper,service写业务逻辑,controller只做参数接收和结果返回。不要为了炫技搞DDD那套,毕设阶段清晰的分层比复杂的架构重要得多。
5.2 JWT登录鉴权与后端权限控制
登录认证是毕设项目的常规要求,我推荐用JWT而不是Session。原因有两点:前后端分离项目天生适合无状态认证;JWT的“token里带用户信息”这个特性,在答辩时可以讲出与Session的差别。
实现步骤大致是:
- 登录成功后,用userId和username生成JWT,设置过期时间,返回给前端;
- 前端把token存在localStorage,每次请求在请求头里带上
Authorization: Bearer <token>; - 后端写一个拦截器,拦截需要登录的接口,校验token合法性和过期时间;
- 校验通过后,把userId放到ThreadLocal中,业务层直接从ThreadLocal取当前用户。
下面是一个简单的JWT工具类骨架:
public String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }毕设阶段不需要做refresh token、多端互踢这类高级功能,一个有效期为24小时的token足够撑起演示。
拦截器里还要顺势解决一个问题:跨域。前后端分离项目必然会碰到CORS跨域,最简单的配置方式是写一个WebMvcConfigurer,重写addCorsMappings方法,允许所有路径跨域,允许带token请求头。如果漏了这一步,前端无论如何调接口都会报“CORS blocked”,这是新手群体里出现频率极高的错误。
5.3 景点检索、分页与缓存加速
用户端最常用的接口大概有三个:景点分页列表、景点搜索、景点详情。这三个接口实现起来都不难,但有一些性能细节值得打磨。
分页列表用MyBatis-Plus的分页插件即可。先注入一个MybatisPlusInterceptor,添加PaginationInnerInterceptor,然后在Service里调用Page对象进行分页查询。结果返回时要把总条数也带上,前端才能正常显示翻页组件。
搜索接口最粗暴的写法是:
page = scenicService.lambdaQuery() .like(Scenic::getName, keyword) .or() .like(Scenic::getCity, keyword) .page(pageParam);这种写法能跑,但性能和准确性都比较一般。想做得好看一点,可以把关键词同时匹配到名称、城市、标签三个维度,然后按匹配维度数量排序。面试时如果有人问你怎么优化搜索,你可以答“引入全文检索或者Elasticsearch”,但毕设阶段用like就够了,不要在搜索上过度投入。
详情页是典型的缓存应用场景。一个景点被反复查看,如果把详情一次次查MySQL,压力全在数据库上。推荐的做法是:首次请求时把景点信息写入Redis,key设计为scenic:detail:{id},设置30分钟过期;后续请求先查缓存,命中了直接返回,没有命中再查库并回填缓存。
用一段简单代码表示:
public Scenic getDetail(Long id) { String key = "scenic:detail:" + id; Object cached = redisUtil.get(key); if (cached != null) { return (Scenic) cached; } Scenic scenic = scenicMapper.selectById(id); if (scenic != null) { redisUtil.set(key, scenic, Duration.ofMinutes(30)); } return scenic; }你不需要真的引入RedisTemplate之外的东西,就能让这个功能成为答辩时的性能亮点。
5.4 项目打包与常见部署方式
毕设最后一步是部署演示。我强烈建议提前一天把所有环境整理好,不要等到答辩当天才手忙脚乱装MySQL、配Redis。
最稳妥的方案是:本地装一个MySQL和Redis,IDEA里直接启动SpringBoot项目,前端用npm run dev启动Vue项目,所有服务都在本机跑。这个方案对环境依赖最小,但答辩时如果电脑配置一般,同时开两个IDE进程和两个中间件,风扇可能会很响。
如果想展示一点工程化能力,可以用Docker Compose一键启动。写一个简单的docker-compose.yml,编排MySQL、Redis、后端应用三个容器:
version: "3" services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: 123456 MYSQL_DATABASE: travel_recommend ports: - "3306:3306" redis: image: redis:7 ports: - "6379:6379" app: build: . depends_on: - mysql - redis ports: - "8080:8080"打包命令也很简单:mvn clean package -DskipTests。这里要提醒一句,如果你的项目使用了Lombok,打包时一定要带上Lombok插件,否则编译可能会报“找不到getter/setter方法”的错误。如果使用的是较新的JDK版本和SpringBoot版本,还要注意Maven要使用3.6以上版本,否则大概率会遇到依赖解析失败的坑。
6. 毕设新人最常踩的坑与答辩加分技巧
6.1 运行期高频报错速查表
我把这些年看过的高频报错整理成一张速查表,基本上遇到问题可以直接对号入座。
| 报错信息 | 常见原因 | 解决办法 |
|---|---|---|
| Access denied for user 'root'@'localhost' | 数据库密码错误 | 检查application.yml里的password配置 |
| The server time zone value 'xxx' is unrecognized | MySQL时区问题 | 连接URL加serverTimezone=Asia/Shanghai |
| Port 8080 was already in use | 端口被占用 | 换一个端口,或在启动时加--server.port=8081 |
| Failed to configure a DataSource | 没有配置数据源或driver依赖缺失 | 确认引用了mysql-connector-java |
| Unable to connect to Redis | Redis服务未启动 | 本地启动redis-server,检查host和port |
| Invalid bound statement (not found) | Mapper接口和XML映射不匹配 | 检查MapperScan扫描路径,确认XML的namespace完整 |
| CORS blocked | 跨域未配置 | 写一个全局CORS配置类 |
| java.lang.ClassNotFoundException | 依赖缺失或打包漏了依赖 | 执行mvn clean package重新构建 |
| 中文乱码 | 连接编码问题 | URL加characterEncoding=utf8 |
尤其要说一下“Invalid bound statement”这个报错,看似诡异,实际上八成是Mapper接口所在包路径没有被SpringBoot扫描到。我建议在主启动类上显式加上@MapperScan("com.example.mapper"),把这个包路径写清楚,能省下大量排查时间。
6.2 如何参考网上的旧项目提高学习效率
网上能找到大量历年的毕设项目源码,最常见的形态是给你一个jar包或者一个完整的源码包。如果拿到的是jar包,想学习内部实现,可以用JD-GUI或Luyten这类反编译工具直接打开查看源码,IDEA自身的反编译功能也可以做到。打开之后,不要漫无目的地逛,先看pom.xml或META-INF里的依赖列表,了解项目用了哪些技术栈;再看application.yml,了解端口、数据库、缓存配置;最后沿着controller → service → mapper这条线把接口读一遍。
这里我想特别强调学习姿态的问题。反编译工具是帮你看懂别人思路的,不是让你换个皮直接交的。很多导师对往年项目的代码风格非常熟悉,你复制一份改个名字,反而可能被一眼识破。聪明的做法是:拿参考项目当“字典”,重点学它的表结构设计、接口规划、算法代码结构,然后自己从头写一遍。哪怕最终写出来和参考项目有七分相似,只要中间过程是你自己走的,答辩时任何提问你都能接得住。
6.3 答辩时的三个加分点
毕设答辩的本质不是听你把代码念一遍,而是考察你有没有独立解决问题的能力。以旅游景点推荐系统为例,有三个方面如果能讲清楚,反而比功能本身更出彩。
第一,把推荐算法的限制说透。比如数据稀疏问题——用户打分少,协方差矩阵大量为空,最终推荐结果可能偏向热门。你可以主动讲出这个问题,并说明你是如何用混合推荐和热度兜底来缓解的。坦诚地承认局限,比吹得天花乱坠更让评委认可。
第二,把设计取舍讲明白。为什么推荐用Redis缓存而不是本地缓存?因为用户行为数据需要跨会话存储,而且Redis支持过期策略。为什么标签独立建表而不是存在主表?因为一个景点对应多个标签,独立表更符合第三范式,也方便后续维护。每一个表设计、每一项技术选型,你都要能说出一个“为什么”。
第三,展示调试和部署能力。现场能从容演示docker部署、说明jar包打包过程、展示日志排查思路,这在评委心里是非常大的加分项。很多学生只会在IDE里点运行按钮,一旦离开开发环境就手足无措,这和真实工作场景脱节严重。提前把部署链路走通,会让你整个人看起来专业很多。
最后再分享一个我自己的习惯:做这种带推荐算法的毕设时,我先从最朴素的热度推荐开始,跑通一整条“用户看景点、管理员传景点、首页出列表”的链路,然后再把协同过滤和混合推荐一层层往上加。这个做法的好处是,每一版都有可运行的成果,心理压力会小很多,而且每一步的增量你都能说清楚自己写了什么。别小看这个节奏安排,很多做到一半想放弃的人,不是能力不行,是早期目标定得太空,每天都看不到成品。先从能跑的最小闭环开始,你会发现自己越做越顺。