又到了毕设选题和开发的冲刺阶段,每年这个时候,后台问得最多的一类问题就是:“能不能推荐一个既不太难、又有技术含量、还能把论文写得丰满的 SpringBoot 项目?”如果你也有同样的需求,同时想避开那种满大街都是的“图书管理”“学生管理系统”,那么本文介绍的“河南特色景点文化推荐系统”会是一个非常合适的选择。
这套系统表面上看是一个景区门户网站,但底子是一个完整的 SpringBoot 前后端分离项目,带用户、景点、文化标签、收藏、评论、浏览记录,还加入了基于用户行为的个性化推荐逻辑。相比普通 CRUD 系统,它在技术点上多了“算法”和“数据驱动”两层内容,无论做毕业设计还是写进简历里的项目经验,都能比同龄人更有说服力。本文不会只贴一张截图,而是把从需求分析、数据库设计、核心代码,到推荐算法实现、部署上线、论文写作的完整链路拆开讲清楚。如果你正准备用 SpringBoot 做毕设,这篇文章值得先收藏再读。
1. 背景与核心概念
1.1 为什么做“景点文化推荐系统”,而不是普通旅游网站
很多旅游类小系统本质上是信息展示平台:管理员录入景点数据,用户浏览列表、查看详情。它的数据库结构不复杂,核心功能也只有增删改查,答辩时几乎无话可说。而“文化推荐”这四个字,把系统从“信息展示”拉升到了“数据服务”层面。
河南本身是一个文旅资源非常密集的省份,景点背后往往沉淀着很强的文化属性:洛阳龙门石窟代表石窟艺术,开封清明上河园代表宋代市井文化,安阳殷墟代表甲骨文与商文明,登封少林寺代表禅武文化。如果系统只把“位置”和“门票价格”存进数据库,就丢了灵魂。所以这个项目的第一个理念是:把“文化标签”作为景点数据的核心字段。每个景点关联一组标签,例如“历史遗迹”“民俗体验”“红色旅游”“自然山水”“美食小吃”等。用户在注册和浏览过程中,系统会逐步收集他对这些标签的偏好,最后通过推荐算法输出“猜你喜欢”的结果。
这在业务层面可以这样理解:一个对“历史遗迹”感兴趣的用户,访问龙门石窟后,系统应该给他推荐殷墟、商丘古城这类景点,而不是推荐只有自然风光但文化属性很弱的景区。这个逻辑听起来很直觉,但在传统 CRUD 系统里是完全做不到的。
1.2 推荐系统在当前项目里属于什么层次
先给一个心理预期:毕设项目不需要做出抖音那种级别的推荐引擎,也不需要引入 Spark、Flink 这类大数据组件。我们只需要在 SpringBoot 项目中实现一个“基于内容(Content-Based)”的轻量级推荐逻辑,就足够覆盖题目中的“推荐”两字。
基于内容的推荐核心只有三步:
- 给每个景点打上标签向量;
- 根据用户历史行为(浏览、收藏、评论),统计用户标签偏好向量;
- 计算用户向量与景点向量之间的余弦相似度,按相似度排序输出推荐列表。
这套算法不需要额外安装中间件,用 Java 集合操作就能实现。数据量在万级别以内,性能完全够用。答辩时如果老师问“为什么不用协同过滤”,你可以回答:协同过滤依赖大量用户行为矩阵,在小规模新上线系统中存在冷启动问题,而基于内容的推荐可以针对单个用户立即产生推荐结果,更适合文化和旅游类垂直场景。这样回答,反而体现了你真正思考过选型。
1.3 系统的整体业务结构
作为一套完整的毕设系统,它的业务范围需要覆盖前台和后台两条线:
前台用户端:
- 用户注册、登录、个人信息维护;
- 景点列表、景点搜索、景点详情展示;
- 文化标签筛选(按历史遗迹、自然山水等筛选);
- 景点收藏、点赞、评论;
- 系统根据用户行为生成个性化推荐列表;
- 个人中心查看我的收藏、我的评论、浏览足迹。
后台管理端:
- 管理员登录;
- 景点信息管理(增删改查、图片上传、标签维护);
- 标签管理;
- 用户管理;
- 评论管理,可以删除违规评论;
- 浏览与收藏数据统计。
在这样的结构下,项目至少涉及用户表、景点表、标签表、景点-标签关联表、收藏表、评论表、浏览日志表、管理员表八张核心表。我们会在第 3 节详细设计。
2. 技术栈选型与项目架构
2.1 2026 年前后端技术栈参考
技术栈的选型要遵循一个基本原则:既要体现主流性,也要保证本地环境能跑起来。不需要盲目追逐最新版本,但也不能用十年前的技术。下面这套组合是当前 SpringBoot 毕设项目里比较稳健的配置:
| 层次 | 技术选型 | 说明 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x 或 3.x | 2.7 兼容性更稳,3.x 要求 JDK17+ |
| 持久层 | MyBatis-Plus | 弱化 SQL 编写,内置分页插件 |
| 数据库 | MySQL 8.0 | 支持 JSON 字段,性能好 |
| 缓存 | Redis 5.x / 6.x | 用于缓存热点景点、存储验证码等 |
| 安全框架 | Spring Security 或 Sa-Token | 毕设首选 Sa-Token,API 简单 |
| 前端 | Vue 3 + Element Plus + Axios | 前后端分离 |
| 构建工具 | Maven | 主流构建工具 |
| 部署 | 本地 Jar 包 + Nginx 反向代理 | 也可用 Docker 打包 |
这里需要说明一下版本问题:Spring Boot 3.x 对 JDK 版本有硬性要求(JDK 17+),如果你的电脑还是 JDK 8,建议直接使用 Spring Boot 2.7.x + JDK 8 的组合,MySQL 8.0 和 MyBatis-Plus 都可以兼容。本文后面的代码示例以 Spring Boot 2.7 风格为主,如果使用 3.x,只需要把javax.*包替换为jakarta.*即可。
2.2 项目整体架构
项目采用前后端分离架构,后端只提供 RESTful API,前端通过 Axios 调用接口。整体请求流程如下:
前端页面(Vue3) -> Nginx(静态资源服务) -> 后端 Controller -> Service -> Mapper -> MySQL / Redis
对应后端工程的包结构如下:
src/main/java/com/henan/travel ├── config // 配置类,跨域、MyBatis-Plus分页插件、Redis配置 ├── controller // 接口层,收参、调Service、返回统一结果 ├── service // 业务层,实现具体业务和推荐算法 ├── mapper // MyBatis-Plus Mapper接口 ├── entity // 数据库实体类,对应表结构 ├── dto // 前端参数对象 ├── vo // 返回给前端的数据对象 ├── common // 统一状态码、统一返回类、异常处理 └── utils // 工具类,如JWT工具如果不会 Spring Security,或者觉得配置繁琐,推荐使用 Sa-Token 做登录认证。Sa-Token 的 API 设计非常简单,登录后调用StpUtil.login(userId),鉴权时调用StpUtil.checkLogin(),半天就能接完。省下来的时间可以用到推荐算法和前端联调上。
2.3 统一响应结构设计
后端接口返回给前端的时候,不建议直接返回实体对象,而是统一包一层 Result,这样前端不管遇到成功还是失败,都能用同一个代码逻辑处理。
// 文件路径:src/main/java/com/henan/travel/common/Result.java public class Result<T> { private Integer code; // 200 表示成功,500 表示失败 private String message; // 提示信息 private T data; // 业务数据 public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } // 省略 getter / setter }下面所有接口的返回都会使用这个结构,例如查询景点详情时返回:
{ "code": 200, "message": "操作成功", "data": { "id": 1, "name": "龙门石窟", "content": "世界文化遗产,位于洛阳市……", "tags": ["历史遗迹", "世界遗产"] } }3. 数据库设计与核心表结构
3.1 数据库概念模型设计
在写代码之前,先把数据库表设计清楚。这里我给出一个适合毕设的物理表结构,直接复制到 Navicat 执行即可。
景点表scenic_spot是最核心的表,包含景点名称、简介、详细内容、图片、区域、开放时间、门票价格等字段,此外还有一个click_count字段,用于记录浏览量,方便做热门景点排行。
标签表tag保存景点文化标签,例如“历史遗迹”“自然山水”“红色旅游”“民俗体验”等。
景点与标签是多对多关系,通过中间表scenic_tag关联。
用户表sys_user保存账号、密码、昵称、头像。密码必须加密存储,推荐使用 BCrypt 算法。
行为表包括collect_record(收藏)、comment_record(评论)、browse_record(浏览日志)。这三张表是推荐算法的数据来源。
3.2 核心建表 SQL
-- 用户表 CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `create_time` datetime DEFAULT NULL COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 景点表 CREATE TABLE `scenic_spot` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '景点ID', `name` varchar(100) NOT NULL COMMENT '景点名称', `summary` varchar(500) DEFAULT NULL COMMENT '景点简介', `content` text COMMENT '景点详细介绍', `cover_image` varchar(255) DEFAULT NULL COMMENT '封面图片', `region` varchar(50) DEFAULT NULL COMMENT '所属地市', `open_time` varchar(100) DEFAULT NULL COMMENT '开放时间', `ticket_price` decimal(10,2) DEFAULT '0.00' COMMENT '门票价格', `click_count` int DEFAULT '0' COMMENT '浏览量', `status` tinyint DEFAULT '1' COMMENT '状态 1上架 0下架', `create_time` datetime DEFAULT NULL COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点表'; -- 标签表 CREATE TABLE `tag` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '标签ID', `name` varchar(50) NOT NULL COMMENT '标签名称', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点标签表'; -- 景点标签关联表 CREATE TABLE `scenic_tag` ( `id` bigint NOT NULL AUTO_INCREMENT, `scenic_id` bigint NOT NULL COMMENT '景点ID', `tag_id` bigint NOT NULL COMMENT '标签ID', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点标签关联表'; -- 收藏记录表 CREATE TABLE `collect_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '用户ID', `scenic_id` bigint NOT NULL COMMENT '景点ID', `create_time` datetime DEFAULT NULL COMMENT '收藏时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏记录表'; -- 评论表 CREATE TABLE `comment_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '用户ID', `scenic_id` bigint NOT NULL COMMENT '景点ID', `content` varchar(500) DEFAULT NULL COMMENT '评论内容', `create_time` datetime DEFAULT NULL COMMENT '评论时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点评论表'; -- 浏览日志表 CREATE TABLE `browse_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '用户ID', `scenic_id` bigint NOT NULL COMMENT '景点ID', `browse_time` datetime DEFAULT NULL COMMENT '浏览时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='浏览记录表';3.3 标签在推荐算法中的作用
标签是整个推荐系统的“语义桥梁”。一个景点如果没有标签,在推荐算法中就是“不可描述”的数据。设计标签时要注意两点:第一,标签粒度要适中,不要细分到“龙门石窟佛像雕刻艺术”这种只有单个景点能匹配的粒度,那会失去泛化能力;推荐使用“历史遗迹”“宗教文化”“自然山水”“文化古镇”“红色旅游”“博物馆”“美食小吃”“节庆活动”这类能覆盖多个景点的中型标签。第二,一个景点建议关联 3 到 5 个标签,太少则信息不足,太多则标签区分度下降。
4. 后端核心功能实现
4.1 景点管理模块
后台管理端最基础的功能是景点维护。使用 MyBatis-Plus 后,Mapper 层几乎不需要写 SQL,只需要继承BaseMapper接口。
// 文件路径:src/main/java/com/henan/travel/mapper/ScenicSpotMapper.java public interface ScenicSpotMapper extends BaseMapper<ScenicSpot> { }Service 层在新增景点时,除了保存景点基本信息,还要维护景点与标签的关联关系。以下是景点新增的核心逻辑:
// 文件路径:src/main/java/com/henan/travel/service/impl/ScenicSpotServiceImpl.java @Service public class ScenicSpotServiceImpl extends ServiceImpl<ScenicSpotMapper, ScenicSpot> implements ScenicSpotService { @Resource private ScenicTagMapper scenicTagMapper; @Override @Transactional(rollbackFor = Exception.class) public void addScenic(ScenicDTO dto) { // 1. 保存景点基本信息 ScenicSpot spot = new ScenicSpot(); BeanUtils.copyProperties(dto, spot); this.save(spot); // 2. 保存景点和标签的关联关系 List<Long> tagIds = dto.getTagIds(); if (tagIds != null && !tagIds.isEmpty()) { for (Long tagId : tagIds) { ScenicTag scenicTag = new ScenicTag(); scenicTag.setScenicId(spot.getId()); scenicTag.setTagId(tagId); scenicTagMapper.insert(scenicTag); } } } }这段代码里有两个值得注意的地方:一是使用了@Transactional事务注解,确保景点信息和标签关联要么都成功,要么都回滚,避免出现“景点保存了但没有标签”的脏数据;二是操作的是 DTO 而不是直接接收前端传来的实体,避免前端传入额外的字段覆盖数据库已有内容。
景点列表接口则使用 MyBatis-Plus 的分页插件,实现按名称模糊搜索、按区域搜索、按标签筛选:
// 文件路径:src/main/java/com/henan/travel/service/impl/ScenicSpotServiceImpl.java @Override public Page<ScenicSpotVO> pageScenic(ScenicQueryDTO query) { Page<ScenicSpot> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<ScenicSpot> wrapper = new LambdaQueryWrapper<>(); // 按名称模糊查询 if (StrUtil.isNotBlank(query.getName())) { wrapper.like(ScenicSpot::getName, query.getName()); } // 按地市查询 if (StrUtil.isNotBlank(query.getRegion())) { wrapper.eq(ScenicSpot::getRegion, query.getRegion()); } // 按浏览量倒序 wrapper.orderByDesc(ScenicSpot::getClickCount); Page<ScenicSpot> result = this.page(page, wrapper); // 转换为VO并填充标签 Page<ScenicSpotVO> voPage = new Page<>(query.getPageNum(), query.getPageSize()); List<ScenicSpotVO> voList = result.getRecords().stream().map(spot -> { ScenicSpotVO vo = new ScenicSpotVO(); BeanUtils.copyProperties(spot, vo); vo.setTags(tagMapper.selectTagsByScenicId(spot.getId())); return vo; }).collect(Collectors.toList()); voPage.setRecords(voList); voPage.setTotal(result.getTotal()); return voPage; }这里有一个常用的优化思路:如果一个景点要查一次标签,列表返回 10 条数据就会产生 10 次查询,也就是传说中的 N+1 问题。毕设规模下问题不大,但如果想写得更好,可以先把当前页所有景点 ID 收集起来,再用一条 SQL 批量查出景点与标签的映射关系,在内存中完成组装。
4.2 基于标签偏好的推荐算法实现
这个模块是本系统技术上的核心,也是论文中最值得浓墨重彩的部分。
推荐算法的第一步,是把用户的历史行为转化成“用户标签偏好向量”。思路如下:
- 查询当前用户最近的浏览记录;
- 查询当前用户的收藏记录;
- 查询当前用户的评论记录;
- 分别赋予不同权重,比如收藏记 3 分,评论记 2 分,浏览记 1 分;
- 通过每条行为关联的景点标签,累加得到用户对每个标签的偏好分数。
假设当前用户交互过的景点有“龙门石窟”和“殷墟”,龙门石窟关联了“历史遗迹”“宗教文化”“世界遗产”,殷墟关联了“历史遗迹”“考古遗址”。其中龙门石窟是收藏的,殷墟是浏览过的,那么用户对“历史遗迹”的偏好分就是 3+1=4,对“宗教文化”是 3,对“考古遗址”是 1。这样得到用户偏好向量。
第二步是把所有景点都表示成标签向量。一个景点关联了哪些标签,向量里对应位置就为 1,否则为 0。第三步是计算用户偏好向量与每个景点向量之间的余弦相似度,按相似度从大到小排列,去掉用户已经浏览过的景点,取前 N 条。
余弦相似度公式为:
similarity = (A · B) / (|A| * |B|)分子是用户向量和景点向量的点积,分母是两个向量模长的乘积。在 Java 中,可以用一个名为RecommendService的服务来实现:
// 文件路径:src/main/java/com/henan/travel/service/impl/RecommendServiceImpl.java @Service public class RecommendServiceImpl implements RecommendService { @Resource private ScenicSpotMapper scenicSpotMapper; @Resource private ScenicTagMapper scenicTagMapper; @Resource private TagMapper tagMapper; @Resource private CollectRecordMapper collectRecordMapper; @Resource private BrowseRecordMapper browseRecordMapper; @Resource private CommentRecordMapper commentRecordMapper; @Override public List<ScenicSpotVO> recommend(Long userId, int limit) { // 1. 查询所有标签 List<Tag> allTags = tagMapper.selectList(null); int tagSize = allTags.size(); // 2. 构建用户标签偏好向量 double[] userVector = buildUserVector(userId, allTags); // 3. 查询所有上架景点 QueryWrapper<ScenicSpot> spotWrapper = new QueryWrapper<>(); spotWrapper.eq("status", 1); List<ScenicSpot> allSpots = scenicSpotMapper.selectList(spotWrapper); // 4. 记录用户已浏览过的景点ID,避免重复推荐 Set<Long> viewedIds = browseRecordMapper.selectList( new QueryWrapper<BrowseRecord>().eq("user_id", userId) ).stream().map(BrowseRecord::getScenicId).collect(Collectors.toSet()); // 5. 计算每个景点与用户偏好的相似度 List<ScenicScore> scoreList = new ArrayList<>(); for (ScenicSpot spot : allSpots) { if (viewedIds.contains(spot.getId())) { continue; } double[] spotVector = buildSpotVector(spot.getId(), allTags); double similarity = cosineSimilarity(userVector, spotVector); if (similarity > 0) { scoreList.add(new ScenicScore(spot, similarity)); } } // 6. 按相似度倒序,取前limit条 scoreList.sort((a, b) -> Double.compare(b.getScore(), a.getScore())); List<ScenicSpotVO> result = new ArrayList<>(); int count = Math.min(limit, scoreList.size()); for (int i = 0; i < count; i++) { // 将景点转换为VO并填充标签,代码略 } return result; } /** * 构建用户标签偏好向量 */ private double[] buildUserVector(Long userId, List<Tag> allTags) { double[] vector = new double[allTags.size()]; Map<Long, Integer> tagIndexMap = new HashMap<>(); for (int i = 0; i < allTags.size(); i++) { tagIndexMap.put(allTags.get(i).getId(), i); } // 收藏权重 3.0 List<CollectRecord> collects = collectRecordMapper.selectList( new QueryWrapper<CollectRecord>().eq("user_id", userId)); for (CollectRecord record : collects) { List<Long> tagIds = scenicTagMapper.selectTagIdsByScenicId(record.getScenicId()); for (Long tagId : tagIds) { Integer idx = tagIndexMap.get(tagId); if (idx != null) { vector[idx] += 3.0; } } } // 评论权重 2.0 List<CommentRecord> comments = commentRecordMapper.selectList( new QueryWrapper<CommentRecord>().eq("user_id", userId)); for (CommentRecord record : comments) { List<Long> tagIds = scenicTagMapper.selectTagIdsByScenicId(record.getScenicId()); for (Long tagId : tagIds) { Integer idx = tagIndexMap.get(tagId); if (idx != null) { vector[idx] += 2.0; } } } // 浏览权重 1.0,只保留最近30条 List<BrowseRecord> browses = browseRecordMapper.selectList( new QueryWrapper<BrowseRecord>().eq("user_id", userId) .orderByDesc("browse_time").last("limit 30")); for (BrowseRecord record : browses) { List<Long> tagIds = scenicTagMapper.selectTagIdsByScenicId(record.getScenicId()); for (Long tagId : tagIds) { Integer idx = tagIndexMap.get(tagId); if (idx != null) { vector[idx] += 1.0; } } } return vector; } /** * 构建景点标签向量 */ private double[] buildSpotVector(Long scenicId, List<Tag> allTags) { double[] vector = new double[allTags.size()]; List<Long> tagIds = scenicTagMapper.selectTagIdsByScenicId(scenicId); for (int i = 0; i < allTags.size(); i++) { if (tagIds.contains(allTags.get(i).getId())) { vector[i] = 1.0; } } return vector; } /** * 计算余弦相似度 */ private double cosineSimilarity(double[] a, double[] b) { double dot = 0.0; double normA = 0.0; double normB = 0.0; for (int i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } if (normA == 0 || normB == 0) { return 0.0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } }这段代码体现了完整的推荐链路。需要特别说明两点:
第一,为什么评分权重是收藏 3、评论 2、浏览 1?这是基于一个常见假设:用户主动收藏的行为比被动浏览更能反映真实偏好,评论则介于两者之间。这些权重值可以写死在常量里,也可以做进后台配置。论文中可以对数值做敏感性分析,说明不同权重对推荐效果的影响,这是加分项。
第二,为什么要过滤掉已经浏览过的景点?如果用户已经浏览过龙门石窟,再把龙门石窟推荐给用户没有意义。过滤逻辑也可以在 SQL 中处理,但用 Java 集合过滤更直观。
当用户没有任何历史行为时,用户向量全为 0,无法计算相似度。这时候触发冷启动策略:直接按浏览量倒序返回热门景点。这是推荐系统里的常见降级方案。
// 冷启动:无行为数据时推荐热门景点 if (isVectorZero(userVector)) { QueryWrapper<ScenicSpot> wrapper = new QueryWrapper<>(); wrapper.eq("status", 1).orderByDesc("click_count").last("limit " + limit); List<ScenicSpot> hotSpots = scenicSpotMapper.selectList(wrapper); // 转换为VO返回 }4.3 用户行为记录接口
推荐算法需要数据,用户行为记录接口就是算法的“数据采集器”。用户浏览景点详情时,前端调用接口记录浏览日志:
// 文件路径:src/main/java/com/henan/travel/controller/BrowseController.java @RestController @RequestMapping("/api/browse") public class BrowseController { @Resource private BrowseRecordService browseRecordService; @PostMapping("/record") public Result<Void> record(@RequestBody BrowseDTO dto) { StpUtil.checkLogin(); // 校验登录 Long userId = StpUtil.getLoginIdAsLong(); browseRecordService.recordBrowse(userId, dto.getScenicId()); return Result.success(null); } }这里使用 Sa-Token 的StpUtil.checkLogin()做登录校验,比 Spring Security 的过滤器链配置简单直观。记录浏览时可以做一个小策略:同一个用户对一个景点短时间内重复浏览,不重复记录,直接更新浏览时间即可,避免日志表被刷爆。
// 文件路径:src/main/java/com/henan/travel/service/impl/BrowseRecordServiceImpl.java @Override public void recordBrowse(Long userId, Long scenicId) { // 查询该用户对该景点最近一条浏览记录 QueryWrapper<BrowseRecord> wrapper = new QueryWrapper<>(); wrapper.eq("user_id", userId).eq("scenic_id", scenicId) .orderByDesc("browse_time").last("limit 1"); BrowseRecord latest = this.getOne(wrapper); if (latest != null) { // 如果最近浏览时间距现在小于30分钟,只更新时间 latest.setBrowseTime(new Date()); this.updateById(latest); } else { BrowseRecord record = new BrowseRecord(); record.setUserId(userId); record.setScenicId(scenicId); record.setBrowseTime(new Date()); this.save(record); } }收藏接口和评论接口与浏览接口思路类似,不再重复贴代码,但在收藏接口中要注意:用户对同一个景点只能收藏一次,插入前先做唯一性校验,否则会出现重复收藏数据。
5. 前台展示与前后端联调
5.1 首页推荐位设计
前台首页是系统的门面,布局一般包括:
顶部导航栏:Logo、首页、景点列表、文化专题、个人中心。
首页内容区域占用三块位置:轮播图展示精选景点;中部是“热门景点 Top10”按点击量排序;最核心的位置是“猜你喜欢”,调用的就是第 4.2 节的推荐接口。
推荐接口返回的数据结构和景点列表基本一致,前端只需要复用同一个景点卡片组件即可。
5.2 Vue 前端调用推荐接口
使用 Vue 3 + Axios 的时候,一般会在src/utils/request.js中封装一个请求实例:
// 文件路径:src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: 'http://localhost:8080/api', timeout: 10000 }) // 请求拦截器:自动携带token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }) // 响应拦截器:统一处理错误 request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error('网络请求失败,请检查后端服务是否启动') return Promise.reject(error) } ) export default request然后在首页组件中调用推荐接口:
// 文件路径:src/views/Home.vue <script setup> import { ref, onMounted } from 'vue' import request from '@/utils/request' import ScenicCard from '@/components/ScenicCard.vue' const recommendList = ref([]) const loadRecommend = async () => { const res = await request.get('/recommend/list', { params: { limit: 8 } }) recommendList.value = res } onMounted(() => { loadRecommend() }) </script>前端组件只负责展示数据,不需要关心推荐算法是如何计算的。这也是前后端分离模式的优势,前端和后端可以并行开发,只需要提前约定好接口文档。
5.3 跨域问题处理
前后端分离开发时,前端运行在 5173 端口(Vite 默认),后端运行在 8080 端口,浏览器会因为跨域拦截请求。解决方案是在后端写一个跨域配置类:
// 文件路径:src/main/java/com/henan/travel/config/CorsConfig.java @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }生产环境部署时,如果前端和后端使用同一个域名,通过 Nginx 反向代理,跨域问题就不存在了。但开发阶段加跨域配置能省很多烦心事。
6. 部署运行指南
6.1 本地开发环境启动步骤
如果你拿到的是完整的源码工程,按照下面的步骤可以快速在本地跑起来。
第一步,准备环境。安装 JDK 8 或 JDK 17(根据 Spring Boot 版本确定),安装 MySQL 8.0,安装 Redis,安装 Node.js 16 以上版本。
第二步,初始化数据库。在 Navicat 中新建数据库henan_travel,字符集选择utf8mb4,然后执行项目 sql 目录下的init.sql脚本。脚本执行完成后,数据库里会生成表结构和基础测试数据。
第三步,修改后端配置。打开application.yml,修改数据源和 Redis 连接信息:
# 文件路径:src/main/resources/application.yml server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/henan_travel?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password redis: host: localhost port: 6379 database: 0 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0第四步,启动后端。在项目根目录执行mvn spring-boot:run,或者在 IDEA 中直接运行主启动类。控制台出现 “Started TravelApplication” 日志即表示启动成功。
第五步,启动前端。进入frontend目录,依次执行:
npm install npm run dev浏览器访问http://localhost:5173,看到首页即表示前后端联调成功。
6.2 生产环境部署思路
毕设演示阶段一般不需要真的买服务器部署,但如果论文中要写“系统部署方案”,可以补充以下内容:
后端打包生成可执行 Jar 包,上传到服务器后使用nohup java -jar travel.jar > travel.log 2>&1 &命令后台运行;前端通过npm run build打包生成 dist 静态文件,配置 Nginx 将根目录指向 dist 文件,同时把/api前缀的请求反向代理到后端 8080 端口。
一个简单的 Nginx 配置片段如下:
server { listen 80; server_name your_domain.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这样配置后,前端页面和后端接口在同一个域名下,不会再出现跨域问题。真正做线上部署时,建议使用 HTTPS 证书加密传输,尤其是登录接口涉及密码传输。
7. 常见问题与排查思路
7.1 启动报错排查表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
启动报Failed to configure a DataSource | 数据库连接信息错误或未安装 MySQL | 检查 application.yml 中的地址、端口、账号密码 |
启动报Access denied for user | 数据库账号权限不足 | 为当前账号授权数据库访问权限 |
启动报Unable to connect to Redis | Redis 未启动或端口不对 | 本地执行redis-server启动 Redis |
| 前端页面打不开 | 未启动前端项目或端口被占用 | 确认 5173 端口未被占用,重新执行npm run dev |
| 接口返回 401 | token 未携带或已过期 | 检查前端请求拦截器是否带上 token |
| 接口返回 500 | SQL 异常或空指针 | 查看后端控制台堆栈信息,定位具体行 |
| 上传图片失败 | 上传目录不存在或权限不足 | 检查 application.yml 中的上传路径配置,创建目录并授权 |
7.2 推荐列表为空
这是推荐模块最容易遇到的问题。用户没有浏览、收藏和评论记录的时候,用户偏好向量全为 0,冷启动逻辑如果没有写对,推荐结果就会是空列表。
排查步骤:
- 确认用户是否登录,后端是否取到了正确的 userId;
- 在数据库中查询
browse_record表,确认是否有该用户的浏览记录; - 在后端日志中打印用户偏好向量,确认向量是否全为 0;
- 确认是否执行了冷启动分支,无行为数据时应该返回热门景点列表。
7.3 MyBatis-Plus 分页失效
分页失效通常是因为没有配置分页插件。MyBatis-Plus 3.4 以上版本需要手动创建分页插件 Bean:
// 文件路径:src/main/java/com/henan/travel/config/MybatisPlusConfig.java @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }忘记配置这个插件时,分页查询不会真正执行 LIMIT 语句,而是把全表数据查出来然后在内存中截取,数据量大时会非常慢。
8. 毕设论文写作与答辩要点
8.1 论文章节结构建议
一套技术型毕设论文通常按照下面章节展开:第一章绪论,写选题背景、国内外研究现状、研究内容;第二章相关技术介绍,写 SpringBoot、MyBatis-Plus、Vue、推荐算法;第三章需求分析,写功能性需求和非功能性需求,配用例图;第四章系统设计,写总体架构、功能模块设计、数据库设计,配 E-R 图和表结构;第五章系统实现,按功能模块展示核心代码和运行截图;第六章系统测试,写测试用例、测试过程和测试结果;最后是总结与展望。
“相关技术介绍”这一章,不要写成百科词条,重点说明“为什么在这个系统中选择该技术”。比如写 Redis 时,不要抄“Redis 是一个高性能的 key-value 存储系统”这种定义,而要写“系统使用 Redis 缓存首页热门景点数据,减少数据库查询压力,同时利用 Redis 的过期策略实现验证码时效控制”。
8.2 答辩高频问题
老师针对推荐系统类毕设,通常会问以下问题,提前准备好答案会从容很多:
这个系统用的是哪种推荐算法?为什么选它?答:基于内容的推荐,根据用户历史行为构建标签偏好向量,与景点标签向量计算余弦相似度,不依赖大规模用户行为矩阵,适合冷启动和小规模场景。
推荐效果怎么评估?答:可以从三个维度说明:一是用户点击率,即推荐位商品的点击次数占总展示次数的比例;二是推荐位商品被收藏的次数;三是用户反馈问卷调查。毕设阶段建议用测试账号模拟不同用户行为,对推荐结果做定性分析。
如果用户量增大到百万级,系统怎么扩展?答:可以从三方面回答:第一,热点景点数据使用 Redis 缓存,降低数据库压力;第二,推荐计算可以使用定时任务预先计算,结果存入 Redis 或数据库中,用户请求时直接读取,避免实时计算;第三,引入搜索引擎 Elasticsearch 做景点全文检索,提高搜索效率。
数据安全性如何保证?答:密码使用 BCrypt 加密存储,接口通过 Sa-Token 做登录拦截,后台管理接口单独做权限校验,前端显示时对用户输入内容做转义防止 XSS 攻击。
8.3 源码和论文文档的整理规范
如果毕设要求提交源码和论文文档,项目根目录建议按照下面的结构整理:
henan-travel-system/ ├── backend/ // 后端SpringBoot工程 │ ├── src/ │ ├── pom.xml │ └── sql/init.sql // 数据库初始化脚本 ├── frontend/ // 前端Vue3工程 │ ├── src/ │ └── package.json ├── docs/ │ ├── 需求分析文档.docx │ ├── 数据库设计文档.docx │ ├── 答辩PPT.pptx │ └── 部署说明.txt └── README.mdREADME.md 中如果写的是“这是基于 SpringBoot 和 Vue3 的河南特色景点文化推荐系统”,记得写明使用步骤、默认管理员账号密码、技术栈列表,方便答辩老师快速了解项目。
9. 总结与后续优化方向
到这里,基于 SpringBoot 的河南特色景点文化推荐系统的核心内容已经完整拆解完了。从数据库设计到推荐算法,从后端接口到前端展示,从本地部署到论文答辩,整条链路形成了一个可以真正落地的毕设项目。你不只是做出了一个“能跑的系统”,而是真正理解了一个带推荐功能的 Web 项目应该怎么设计数据结构、怎么沉淀用户行为、怎么把数据转化成推荐结果。
如果你准备拿这个题目做毕设,优先关注三件事:第一,先把数据库表和基础 CRUD 跑通,保证系统“能用”;第二,完善用户行为记录,保证推荐算法“有数可用”;第三,花时间把推荐算法的代码讲清楚,这是整个项目技术的“最高地标”。
后续如果想继续深化,可以沿着三个方向优化:第一个方向是引入协同过滤,当系统同时具备用户对景点的评分数据后,用基于物品的协同过滤计算“看过 A 景点的人还看过哪些景点”,和基于内容的推荐形成互补;第二个方向是加入搜索权重,把景点的文化标签、区域、名称做成综合检索,提高用户找景点的效率;第三个方向是做移动端适配,把前端改造成响应式布局,或者单独出一套微信小程序端。这些方向都能写进论文的“展望”部分,让结尾不再空洞。
项目源码和论文文档的完整版本,如果后面有时间整理,我会再单独发一篇说明文档,把数据库初始化脚本、前端页面截图和论文模板的使用方式都给大家讲清楚。觉得这篇文章对你有帮助,可以先收藏,免得真正开始做毕设的时候找不到。动手写代码之前,建议你再把第 3 节的数据库设计和第 4.2 节的推荐算法过一遍,这两个部分就是整套系统的地基和心脏。