
每年三四月份总有一批人被毕设选题逼到失眠。今天想拆的这个项目——基于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 SQLBaseMapper自带增删改查条件构造器也能满足大部分查询需求学习成本非常低。缓存选Redis。一是因为Redis是面试高频考点写在简历上是加分项二是它的数据结构很适合存用户行为数据比如用ZSET存浏览记录用STRING存热点景点的详情缓存。毕设项目不需要引入太复杂的缓存策略做到“热点数据缓存 缓存过期”就足够了。前端方面我建议直接使用Vue3 Element-Plus Axios做成前后端分离项目。如果你对前端不太熟也可以直接用Thymeleaf配合Bootstrap做服务端渲染少一套跨域问题代码量也少。但这里有个权衡前后端分离项目在答辩时展示性更强接口调用和参数传递能讲得更清楚也更贴近目前公司的开发模式。我的建议是如果距离提交还有一个月以上咬咬牙上Vue3加分如果时间已经很紧就用Thymeleaf保底把精力留给后端和推荐算法。3. 数据库建模与用户行为数据体系3.1 核心表设计七张表撑起整个系统旅游景点推荐系统的表设计并不复杂关键是要让“用户—景点—行为”这个三方关系清晰。按我的习惯我会把它拆成七个核心表。表名主要字段作用说明userid, username, password, nickname, avatar, city, created_time用户基本信息city可选用于基于城市的兜底推荐scenicid, name, city, category, description, address, cover_url, score, heat, status景点主体表heat存热度值status控制上下架ratingid, user_id, scenic_id, score, comment, created_time用户评分表评分范围1-5推荐算法的重要输入favoriteid, user_id, scenic_id, created_time收藏表表示正向偏好比评分数据更稀疏但更可信commentid, user_id, scenic_id, content, created_time评论表和管理端评论审核功能对应scenic_tagid, scenic_id, tag景点标签表一个景点多个标签做基于内容推荐时用adminid, 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是景点IDscore是当前时间戳。这样既能记录用户最近浏览的景点列表又能按时间排序后续做“猜你喜欢”时可以直接拿这些景点ID去匹配标签。定期把Redis中的浏览记录同步到数据库的user_history表然后清空Redis避免数据丢失。这个“Redis当缓冲、数据库做持久化”的思路听起来挺高级实际实现只要一个定时任务就能搞定答辩时可以重点讲。有了行为数据推荐引擎才有的可吃。评分数据用于协同过滤计算相似度收藏和浏览数据用于构建用户标签画像热度数据用于兜底推荐。三者结合就是一个完整可讲的推荐逻辑。4. 旅游景点推荐算法从原理到代码落地4.1 基于用户的协同过滤先找志同道合的人基于用户的协同过滤是推荐算法里最经典、也最适合毕设讲解的方案。它的思想可以用一句话概括和你口味相似的用户喜欢的景点你大概率也会喜欢。具体分三步根据用户对景点的评分或收藏记录计算用户之间的相似度找到与当前用户最相似的K个用户把这K个用户喜欢过、但当前用户没见过的景点按得分排序推荐出来。相似度计算最常用的是余弦相似度。我直接给出一个可以跑通的伪代码版本建议你先理解思路再根据自己的表结构调整。/** * 计算两个用户基于评分的余弦相似度 * key为景点IDvalue为评分或行为权重 */ public double cosineSimilarity(MapLong, Double user1, MapLong, Double user2) { SetLong 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实现一点不难难的是数据的维护。所以我在写业务时会把“用户偏好标签”固化成一个Servicepublic MapString, Integer buildUserTagProfile(Long userId) { MapString, 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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继承BaseMapperservice写业务逻辑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压力全在数据库上。推荐的做法是首次请求时把景点信息写入Rediskey设计为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和RedisIDEA里直接启动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 rootlocalhost数据库密码错误检查application.yml里的password配置The server time zone value xxx is unrecognizedMySQL时区问题连接URL加serverTimezoneAsia/ShanghaiPort 8080 was already in use端口被占用换一个端口或在启动时加--server.port8081Failed to configure a DataSource没有配置数据源或driver依赖缺失确认引用了mysql-connector-javaUnable to connect to RedisRedis服务未启动本地启动redis-server检查host和portInvalid bound statement (not found)Mapper接口和XML映射不匹配检查MapperScan扫描路径确认XML的namespace完整CORS blocked跨域未配置写一个全局CORS配置类java.lang.ClassNotFoundException依赖缺失或打包漏了依赖执行mvn clean package重新构建中文乱码连接编码问题URL加characterEncodingutf8尤其要说一下“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里点运行按钮一旦离开开发环境就手足无措这和真实工作场景脱节严重。提前把部署链路走通会让你整个人看起来专业很多。最后再分享一个我自己的习惯做这种带推荐算法的毕设时我先从最朴素的热度推荐开始跑通一整条“用户看景点、管理员传景点、首页出列表”的链路然后再把协同过滤和混合推荐一层层往上加。这个做法的好处是每一版都有可运行的成果心理压力会小很多而且每一步的增量你都能说清楚自己写了什么。别小看这个节奏安排很多做到一半想放弃的人不是能力不行是早期目标定得太空每天都看不到成品。先从能跑的最小闭环开始你会发现自己越做越顺。