资讯详情

SpringBoot3+Vue3旅游景点推荐系统:基于标签画像的推荐算法实战

📅 2026/10/12 10:14:07 | 华诺云谱 👁 阅读
SpringBoot3+Vue3旅游景点推荐系统:基于标签画像的推荐算法实战
1. 选题价值与技术底座为什么是“旅游景点推荐系统”每年毕业季都有大量计算机专业的同学在选题上纠结到失眠。说实话“XX管理系统”这个题目已经被玩坏了——前台后台加个增删改查连外行都能猜出来你做了什么东西。我当时选“旅游景点推荐系统”这个题目核心就一个原因它有真正的业务逻辑可以讲有算法可以落地能让答辩现场的评委眼前亮一下。旅游景点推荐本质上是解决“信息过载”问题。游客打开APP看到几百个景点到底选哪个系统根据用户的行为、偏好标签把最匹配的景点排在前面这就比单纯的管理系统多了“智能感”。而且这个推荐算法完全可以自己实现不需要调第三方API答辩时能讲清楚每个公式的含义这是拿高分的关键。再说技术栈。SpringBoot3 Vue3这个组合在当下非常能打。SpringBoot3基于JDK17底层是Spring Framework 6性能、安全性都有明显升级Vue3配合Vite构建启动速度和热更新体验比Vue2 Webpack舒服太多。前后端分离架构也是目前企业开发的主流形态写在简历上不心虚。这个项目适合什么人群零基础但有Java和前端基础课知识的学生想系统做一遍完整项目的人。整套做完你学到的不仅是CRUD还有用户标签建模、相似度计算、异步请求处理、跨域解决、JWT鉴权这些真实开发中天天用的东西。2. 核心模块拆解推荐系统究竟推荐的是什么2.1 用户和景点两端的标签画像建模推荐系统的地基不是算法而是数据模型。我在设计数据库时最花心思的就是标签体系这也是很多同学拿到类似题目容易忽略的地方。先想清楚一个场景一个用户喜欢“古镇”“摄影”“美食”另一个用户喜欢“海边”“亲子”“水上项目”系统给他们推荐的内容应该完全不一样。所以景点要有“特性标签”用户要有“偏好标签”两者算相似度才能产出个性化推荐。具体实现上我设计了三张关联表user用户基本信息外加偏好标签字段用逗号分隔存储比如“古镇,摄影,美食”。scenic景点基本信息包括名称、简介、所在城市、图片地址、评分、开放时间。scenic_tag景点的标签映射表N对N关系一个景点有多个标签一个标签关联多个景点。用户表里的偏好标签我在注册时通过“选择感兴趣的类型”收集包含热门标签如自然风光、历史古迹、主题乐园、博物馆、美食探店等保证标签颗粒度不会过细也不会过粗。标签太细推荐结果稀疏几乎没有可推荐的内容标签太粗所有用户都被推荐同样的内容个性化无从谈起。实践下来10到15个一级标签是合理的区间。2.2 数据库表结构设计与初始化数据表结构这块直接给出我的建表SQL抄了就能用CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码BCrypt加密, nickname varchar(50) DEFAULT COMMENT 昵称, avatar varchar(255) DEFAULT COMMENT 头像地址, preference_tags varchar(255) DEFAULT COMMENT 偏好标签逗号分隔, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE scenic ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, name varchar(100) NOT NULL COMMENT 景点名称, summary text COMMENT 景点简介, city varchar(50) DEFAULT COMMENT 所在城市, address varchar(255) DEFAULT COMMENT 详细地址, cover_image varchar(255) DEFAULT COMMENT 封面图地址, score decimal(2,1) DEFAULT 0.0 COMMENT 用户评分0.0-5.0, open_time varchar(50) DEFAULT COMMENT 开放时间, ticket_price decimal(10,2) DEFAULT 0.00 COMMENT 门票价格, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点表; CREATE TABLE scenic_tag ( id bigint(20) NOT NULL AUTO_INCREMENT, scenic_id bigint(20) NOT NULL COMMENT 景点ID, tag_name varchar(50) NOT NULL COMMENT 标签名, PRIMARY KEY (id), KEY idx_scenic_id (scenic_id), KEY idx_tag_name (tag_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点标签表;初始化数据时我人工整理了30个国内知名景点每个景点匹配3到5个标签。30个景点的量不算大但足够把推荐算法跑起来也方便答辩时演示效果。注意景点简介务必写清楚至少100字否则前端详情页会显得很空答辩演示会很拉胯。时间字段统一用datetime而非timestamp因为timestamp的2038年问题在面试里被问过太多次了直接在毕设里避开这个坑细节处理得越干净越能体现编码素养。2.3 项目配置连接池与logback日志框架SpringBoot3的项目配置有两个容易踩坑的地方我要单独拎出来说。第一是数据源配置。SpringBoot3默认使用HikariCP连接池不需要额外引入依赖只需要在application.yml里配置参数。这里有一个关键点连接池的初始大小和最大大小不能随意填。我的毕设项目并发量低设置如下spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/travel_recommend?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 hikari: connection-timeout: 30000 maximum-pool-size: 10 minimum-idle: 5第二是日志配置。很多教程会让你直接在application.yml里配logging.level但这个项目我强烈建议用logback-spring.xml独立配置文件理由有三可以在不同环境下切换日志级别可以把SQL日志和业务日志分开线上排查问题时能快速定位。SpringBoot3天然集成Logback直接放在resources目录下会被自动加载。?xml version1.0 encodingUTF-8? configuration property nameLOG_PATH value${LOG_PATH:-./logs} / property nameLOG_PATTERN value%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n / appender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern${LOG_PATTERN}/pattern charsetUTF-8/charset /encoder /appender appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_PATH}/app.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern${LOG_PATH}/app.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern${LOG_PATTERN}/pattern charsetUTF-8/charset /encoder /appender logger namecom.example.travel.mapper levelDEBUG additivityfalse appender-ref refCONSOLE / appender-ref refFILE / /logger root levelINFO appender-ref refCONSOLE / appender-ref refFILE / /root /configuration这样配置之后MyBatis的SQL语句会在控制台打印出来联调排错太方便了。我见过太多同学因为没配日志SQL报错却不知道错在哪里对着前端页面干瞪眼最后发现是字段名拼写错误。日志就是程序员的探照灯一定要在一开始就配好。3. 后端落地实践SpringBoot3工程结构设计与推荐算法实现3.1 项目初始化和依赖管理SpringBoot3.0及以上版本要求JDK17这一步就卡掉了很多还在用JDK8的同学。如果你的电脑装的是JDK8请先装JDK17并配置好环境变量否则项目启动直接报错。另外创建项目时我建议直接在Spring Initializrstart.spring.io生成工程模板不要自己手搭Maven目录结构可以少踩很多坑。核心的Maven依赖就这几样dependencies !-- Web 支持 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis Plus减少CRUD样板代码 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- MySQL 驱动 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- JWT 鉴权 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency !-- Lombok简化实体类 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies用MyBatis Plus而不是原生MyBatis的原因很简单毕设项目时间紧张单表CRUD用MyBatis Plus直接继承BaseMapper接口就能搞定省去写大量XML的麻烦。但注意推荐算法相关的查询别偷懒用QueryWrapper拼推荐部分的SQL逻辑较复杂我直接在Mapper中手写SQL性能和可读性都有保障。3.2 推荐算法的选型与代码实现这是整套系统的灵魂也是答辩时最能讲出干货的地方。我用的是基于标签的相似度推荐算法属于推荐系统里的经典入门方案。它的核心逻辑是把用户偏好和景点标签向量化计算用户与每个景点的相似度分数分数越高推荐优先级越高。具体计算用的是余弦相似度公式表示为cos(θ) A·B / (|A| × |B|)A是用户的偏好向量B是景点的标签向量分母是两者模长的乘积。余弦相似度的好处是它对向量的“长度”不敏感只看方向上的匹配度很适合标签这种0/1向量场景。我用的是简化的加权版本更适合答辩时讲解推荐分数 用户偏好与景点标签的共同标签数 × 分值加权举个例子用户偏好是“古镇,摄影,美食”景点A的标签是“古镇,摄影,自然风光”共同的标签是“古镇”和“摄影”两个推荐分算出来就比只有“自然风光”共同项的景点B高。如果有同分的情况再用景点评分和浏览次数做二次排序。代码实现上我先写一个服务类做推荐计算Service public class RecommendServiceImpl implements RecommendService { Resource private ScenicMapper scenicMapper; Override public ListScenicVO recommendForUser(String preferenceTags) { // 1. 解析用户偏好标签 ListString userTags Arrays.stream(preferenceTags.split(,)) .map(String::trim) .filter(s - !s.isEmpty()) .collect(Collectors.toList()); // 2. 获取全部景点及其标签 ListScenicTagDTO scenicTags scenicMapper.selectAllWithTags(); MapLong, ListString tagGroupByScenic scenicTags.stream() .collect(Collectors.groupingBy(ScenicTagDTO::getScenicId, Collectors.mapping(ScenicTagDTO::getTagName, Collectors.toList()))); // 3. 计算每个景点的推荐分数 ListScenicScoreDTO scoreList new ArrayList(); for (Map.EntryLong, ListString entry : tagGroupByScenic.entrySet()) { ListString scenicTagList entry.getValue(); // 求偏好标签与景点标签的交集数量 long overlap userTags.stream().filter(scenicTagList::contains).count(); // 用户标签向量模长 double userNorm Math.sqrt(userTags.size()); // 景点标签向量模长 double scenicNorm Math.sqrt(scenicTagList.size()); double score 0; if (userNorm 0 scenicNorm 0) { score overlap / (userNorm * scenicNorm); } scoreList.add(new ScenicScoreDTO(entry.getKey(), score)); } // 4. 按分数降序再按景点评分降序取前10 ListScenicVO result scoreList.stream() .sorted(Comparator.comparing(ScenicScoreDTO::getScore).reversed() .thenComparing(this::getScenicScore, Comparator.reverseOrder())) .limit(10) .map(this::toVO) .collect(Collectors.toList()); return result; } private double getScenicScore(ScenicScoreDTO dto) { return scenicMapper.selectById(dto.getScenicId()).getScore(); } }这段代码我实测过逻辑完全通。有的同学问要不要用协同过滤我个人觉得对于毕设题目来说协同过滤的实现复杂度和数据量要求更高如果没有数千条评分数据兜底效果反而不如标签推荐。答辩时你先把标签推荐讲透再补一句“后续可以扩展协同过滤算法”考官会认为你有设计层次的思考。3.3 接口层规范统一返回体与全局异常处理前后端分离的项目接口返回格式不统一是后期联调最痛苦的事情。我习惯用一个统一的结果封装类所有接口都返回这个格式{ code: 200, message: 操作成功, data: {} }具体代码是一个泛型类ResultT包含静态方法Result.success(data)和Result.error(code, msg)这个几乎所有项目通用我就不贴完整代码了说三个容易忽略的细节。第一个是全局异常处理一定要覆盖所有自定义业务异常和未知异常。我只是RestControllerAdvice加ExceptionHandler的事但很多同学异常没处理完接口直接把500堆栈抛给前端既难看又不安全。第二个是分页接口的返回结构要带上总数和总页数。用MyBatis Plus的Page对象直接返回即可前端才能正确计算分页组件的页面数量。第三个是参数校验。注册接口的密码、用户名不能为空我用了Validated注解加实体类字段上的NotBlank校验绝对不要在Controller里手写一堆if判断。4. 前端Vue3工程化实战从Vite构建到组件封装4.1 快速搭建Vue3 Vite Element Plus前端部分我用的Vite作为构建工具创建命令npm create vitelatest travel-frontend -- --template vue cd travel-frontend npm install npm install vue-router4 pinia element-plus axios这里有个非常重要的建议路由用createWebHistory模式别用createWebHash模式。虽然Hash模式部署时不容易出404问题但History模式才是企业真实项目的主流选择面试被问到路由模式的区别你能答上来“History模式需要服务端配合做history fallback”这就是加分项。Vite启动的默认端口是5173后端SpringBoot的端口是8080前后端分离必然遇到跨域。解决跨域有两个方案我在项目里用了生产上更常见的后端CORS配置类上标注CrossOrigin太零散我直接用全局配置类Configuration public class CorsConfig { Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }; } }注意allowCredentials(true)必须和allowedOriginPatterns搭配使用不能和allowedOrigins(*)共存否则浏览器会报错。这个坑我踩过用allowedOrigins(*)加allowCredentials(true)前端请求直接CORS error排查了大半天。4.2 核心页面路由设计与状态管理前端页面我规划了6个核心视图首页展示推荐景点列表带Banner轮播景点列表页支持按城市、标签筛选景点详情页展示图文信息、评分、开放时间登录/注册页注册时勾选偏好标签个人中心页展示个人信息、收藏列表、浏览历史后台管理页景点CRUD统计报表路由文件用懒加载方式引入组件防止首屏加载过慢const routes [ { path: /, name: Home, component: () import(../views/HomeView.vue), meta: { title: 首页 } }, { path: /login, name: Login, component: () import(../views/LoginView.vue), meta: { title: 登录 } }, // ... ]Pinia是Vue3推荐的状态管理库和Vuex比它更轻量、没有mutation的概念直接改state就能实现响应式。我在项目里只维护两个store一个userStore存储登录状态和用户信息一个preferenceStore存储用户选择的偏好标签。登录之后userStore里的token用localStorage持久化每次请求都通过Axios拦截器在请求头带上axios.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })同时用响应拦截器统一处理401状态码过期跳转登录页。这些虽然是常规操作但整套做下来你的前端功底就完整了。4.3 景点卡片组件与推荐列表的渲染推荐列表是项目里视觉上最核心的部分每个景点卡片要展示封面图、名称、城市、标签、评分、简介摘要。我封装了一个ScenicCard.vue组件在首页和列表页复用。组件的核心部分长这样template div classscenic-card clickgoDetail el-image :srcscenic.coverImage fitcover classcover-img / div classinfo div classtitle-row span classname{{ scenic.name }}/span el-tag sizesmall typewarning{{ scenic.city }}/el-tag /div div classtags el-tag v-fortag in scenic.tags :keytag sizesmall{{ tag }}/el-tag /div div classbottom-row el-rate :model-valuescenic.score disabled / span classprice{{ scenic.ticketPrice }}/span /div /div /div /template首页推荐列表的逻辑跟后端接口对接调用recommendForUser推荐接口。如果用户未登录则按景点评分倒序返回默认列表。这样设计的好处是未登录访客也能正常浏览登录后立刻能看到个性化差异演示效果非常直观。我见过一些毕设不登录就进不去系统评委想看首页都看不了体验很差千万别学这种。5. 前后端联调、打包部署与答辩避坑手册5.1 联调阶段的典型问题与定位方法前后端联调是毕设开发中最容易让人崩溃的阶段拿我实测踩过的坑来说有四个问题出现频率最高第一跨域配置之后依然报CORS错误。排查思路是先开浏览器DevTools看Network确定发出的是不是OPTIONS预检请求再看后端有没有正确返回CORS响应头。如果OPTIONS请求被Spring Security拦截了需要在Security配置里放行OPTIONS请求。第二登录之后接口依然提示401。检查JWT拦截器的放行路径是否配置正确例如登录接口、注册接口、景点列表接口都需要放行只有个人信息之类的接口才拦截。第三MyBatis Plus的实体字段映射失败。如果数据库字段是create_time这种下划线命名而实体类是createTime驼峰命名需要在application.yml中开启下划线转驼峰配置mybatis-plus: configuration: map-underscore-to-camel-case: true第四Element Plus的组件样式不生效。大概率是因为没导入样式文件用组件库的时候记得全量引入或者按需引入。全量引入最简单。5.2 项目打包与上线部署后端打包进入项目根目录执行mvn clean package -DskipTests生成的jar包在target目录下上传到服务器至少2G内存用nohup java -jar xxx.jar 启动。记得生产环境的数据库密码不要写在application.yml里明文暴露用环境变量引用比如${DB_PASSWORD}部署时在启动脚本里声明环境变量。前端打包npm run build构建产物在dist目录用Nginx托管同时在Nginx配置前端History模式路由回退server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } }这里的try_files就是History路由模式必须的配置。不配的话用户刷新页面就404。5.3 答辩环节的高频问题与话术整理答辩时评委最喜欢问的问题我提前整理了一份清单照着准备基本稳了“推荐算法的原理是什么”——讲清楚余弦相似度的思想重点说“共同标签越多分数越高”这一条直观逻辑别背公式要讲人话。“为什么选SpringBoot3不用SpringBoot2”——答JDK版本提升、性能增强、Spring Framework 6的内核升级再补一句新项目当然用新框架。“登录是怎么保证安全的”——答JWT无状态鉴权密码BCrypt加密讲出token过期和续签的思路。“如果用户数增长到十万推荐系统还会快吗”——先承认索引优化和缓存是下一步再答MySQL查询优化、Redis缓存热门景点列表、推荐结果预计算展示你有扩展思维。这里有一个很重要的答辩技巧不要把自己的毕设说成商业级产品。评委问优化问题你要承认现有方案适用校园场景并提出1到2个明确的演进方向比如增加Redis缓存。诚实且清醒的学生比吹牛的学生更讨人喜欢。另外论文目录结构也要和技术架构对齐我建议包含绪论、需求分析、系统设计、数据库设计、系统实现、系统测试。每个章节写的时候技术细节要和代码完全对应避免“代码做了A但论文写了B”这种低级错误盲审老师真的会逐行对照的。6. 后续可以怎么扩展这个项目这个旅游推荐系统做完以后我仔细复盘过后续还有三个很自然的演进方向。如果你想在毕设基础之上做点延伸或者之后放在作品集里继续维护可以参考一下。第一个是加入协同过滤算法做混合推荐。基于标签相似度适合新用户冷启动但用户行为数据积累到一定程度之后协同过滤可以利用“和你相似的人喜欢什么”来补充推荐精度。两种算法加权融合就是工业界常用的混合推荐策略。第二个是增加Redis缓存和消息队列把热门景点列表、首页推荐结果缓存起来写入操作投递到队列异步处理。这一套做完简历上就可以写“高并发场景下的缓存与异步优化”含金量立刻不一样。第三个是引入地图接口景点位置用经纬度存储前端地图上展示周边景点移动端适配后再做小程序版本。这个扩展方向对旅游类别身非常契合。从我个人的经验来看做毕业设计最重要的是“做出来”和“讲清楚”。代码能跑通只是第一步你要对每一个关键决策都有清晰的思考过程这才是答辩拿高分或者面试聊项目时不心虚的底气。这套SpringBoot3 Vue3的旅游景点推荐系统从数据库建模到推荐算法落地从跨域联调到打包部署每一步我都实际走了一遍踩过的坑也都写在这篇教程里了。如果你正在做类似题目完全可以按这个思路顺着走一遍比闷头看视频课效率高得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑