开篇:从一个暑期项目说起
前阵子带一个学弟做毕业设计,题目就是"springboot旅游景点推荐管理系统设计实现"。做之前以为就是个普通的增删改查管理系统,结果真正搭起来才发现,一个完整的景点推荐系统,牵扯到用户行为采集、推荐算法、缓存策略、前端联调一堆东西,远不是把 Spring Boot 跑起来那么简单。这篇文章把我整个做项目的思路、踩过的坑、最后落地的方案完整记录下来,给后面要做类似系统的人一个参考。
先说清楚这个系统到底做什么。它是给旅游平台的管理员和普通游客用的:游客可以浏览景点、搜索目的地、查看评价、给景点打分,系统会根据用户的历史行为和偏好自动推荐景点;管理员负责景点信息的上架下架、审核评论、统计访问数据。技术栈就是 Spring Boot + MyBatis-Plus + MySQL + Redis,前端用 Vue 搭的简易后台,推荐模块用的协同过滤加内容匹配的混合策略。
适合哪些人看?准备做 Spring Boot 毕设或课设的同学,想了解推荐系统怎么落地到业务项目里的开发者,以及正在纠结"管理系统怎么能做得不那么像玩具"的同行。这里面没有那种被你抄了一百遍的登录注册代码,重点放在推荐模块的工程化实现和性能优化上。
1. 项目整体定位与功能蓝图
1.1 需求分析:这个系统真正要解决什么
很多人做管理系统容易陷入一个误区——把管理端的 CRUD 做完就开始写论文,用户端随便糊弄一个列表就交差。但旅游景点推荐管理系统,核心价值恰恰在"推荐"这两个字上。如果只是做一个景点信息维护后台,那它就是普通的内容管理系统,没有任何竞争力。
所以在设计前先想清楚三个问题:
- 游客端:用户来了能看到什么?怎么让用户快速找到感兴趣的景点?怎么把平台的存量内容推给合适的人?
- 管理端:运营人员怎么高效维护上千条景点数据?怎么处理用户产生的评论、订单、收藏行为数据?
- 系统层面:景点数据多了之后,列表查询会不会慢?推荐计算能不能做到实时响应?数据一致性怎么保证?
基于这三点,整个系统拆成了六大功能模块:用户管理、景点管理、评论管理、收藏管理、推荐引擎、数据统计。每个模块在数据库设计阶段就要留好扩展空间,不能后面加需求了才回头改表结构。
1.2 用户角色与权限设计
系统分了三种角色,权限模型用的是最经典的 RBAC(基于角色的访问控制):
| 角色 | 核心权限 | 典型操作 |
|---|---|---|
| 普通用户 | 浏览景点、搜索、收藏、评论、评分 | 查看推荐列表,对景点打分,写评价 |
| 景区管理员 | 管理本景区信息 | 更新景点介绍、上传图片、回复评论 |
| 平台管理员 | 全局配置与审核 | 审核景点上架、封禁违规用户、查看数据报表 |
这里有个容易被忽略的细节:用户表和角色的关联,我建议用中间表而不是在用户表里写死一个 role 字段。虽然项目初期只有三种角色,看起来一个字段够用了,但后期如果冒出"VIP 用户""内容审核员"这类角色,硬编码字段就得频繁改表。用 Spring Security 做权限控制时,直接基于角色标识判断接口访问权限,扩展起来非常方便。
实际开发中,权限控制粒度也值得斟酌。管理端的接口我统一用@PreAuthorize("hasRole('ADMIN')")做粗粒度拦截,细粒度的数据权限(比如景区管理员只能改自己景区)放在 Service 层通过判断当前登录用户的 ID 实现。这样既不会在 Controller 里堆满权限代码,又能保证数据隔离。
2. 技术选型与架构设计
2.1 后端框架为什么选 Spring Boot
Spring Boot 现在基本是 Java Web 开发的事实标准,选它没什么悬念。但具体到版本,我的建议是别追最新版本,稳定优先。我用的 Spring Boot 2.7.18,配 JDK 8,这个组合经过了大量生产环境验证,网上随便一搜就是解决方案。
搭配的组件如下:
- 持久层:MyBatis-Plus 3.5.3,自带分页插件和代码生成器,写单表 CRUD 能省一半工作量
- 权限:Spring Security + JWT(无状态 token 方案,接口鉴权不需要依赖 Session)
- 缓存:Redis 6.x,用来缓存热点景点信息和用户推荐列表
- 数据库:MySQL 8.0,InnoDB 引擎
- 前端:Vue 2 + Element UI,管理端用的现成模板,用户端是简单的 HTML 页面
2.2 数据库设计的几个关键决策
数据库设计是这类系统的重中之重,表结构直接决定后面的开发和性能。我最终设计了七张核心表:
t_user 用户表(id, username, password, nickname, avatar, role...) t_scenic 景点表(id, name, location, description, rating, cover_url...) t_comment 评论表(id, user_id, scenic_id, content, rating, create_time...) t_favorite 收藏表(id, user_id, scenic_id, create_time...) t_category 分类表(id, name, parent_id...) t_scenic_category 景点-分类关联表(scenic_id, category_id) t_behavior 行为日志表(id, user_id, scenic_id, behavior_type, create_time...)需要解释一下为什么单独建了一张t_behavior行为表。推荐系统依赖用户行为数据,包括浏览、收藏、评分、评论。如果行为数据都散落在各自的业务表里,推荐模块要聚合这些数据就得跨表查询,效率极低。单独存一张行为表,推荐计算时直接按用户维度拉取行为记录,干净利落。
另一个关键点是索引设计。t_behavior表建了(user_id, scenic_id)联合索引,查询用户行为时走索引,速度很快。t_comment表在scenic_id上建了普通索引,景点详情页要加载评论列表,这个查询频率极高。
2.3 缓存策略:Redis 在这里到底缓存什么
Redis 的引入一定要讲究性价比。不是所有数据都适合放缓存,我实践下来,两类数据收益最高:
- 景点详情缓存:热门景点的基础信息基本不变,但被大量用户反复访问。用 Redis 存一份 JSON 序列化后的景点数据,key 设计成
scenic:detail:{id},TTL 设置为 24 小时,后台修改景点信息时主动删除对应缓存。 - 用户推荐列表缓存:推荐结果计算成本高,如果每次都实时算,接口响应时间会很难看。把推荐结果缓存到
recommend:user:{userId},TTL 设置 2 小时,用户再次请求推荐时直接返回缓存。
实操经验:给缓存 key 加前缀的时候,一定要考虑业务前缀的隔离性。我就是一开始偷懒,key 全用的scenic:{id},后来加了评论缓存才发现comment:{id}和scenic:{id}混在同一个 Redis 实例里,排查问题的时候眼睛都快看瞎了。
3. 核心功能模块的实现细节
3.1 用户登录鉴权:JWT 为主,Session 为辅的设计
权限这块我用 JWT 做无状态认证,流程是:用户提交用户名密码,校验通过后生成 token 返回给前端,前端每次请求在 Authorization 头里带上 token,后端通过过滤器统一解析。
核心代码如下:
@Service public class AuthService { @Autowired private StringRedisTemplate redisTemplate; private static final String SECRET_KEY = "your-secret-key-for-jwt"; private static final long EXPIRATION = 24 * 60 * 60 * 1000; // 24小时 public String createToken(User user) { return Jwts.builder() .setSubject(user.getId().toString()) .claim("username", user.getUsername()) .claim("role", user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } public User getUserFromToken(String token) { Claims claims = Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); // 从缓存中获取完整用户信息,避免每次请求都查库 String userJson = redisTemplate.opsForValue().get("user:token:" + claims.getSubject()); return JSON.parseObject(userJson, User.class); } }这里有一个工程上的细节:JWT 本身可以携带用户信息,但如果你在用户修改资料后想立即让 token 里的信息失效,直接改 token 是不行的,因为 JWT 是无状态的。我的做法是登录成功后把用户详细信息写入 Redis,鉴权时优先从 Redis 获取用户数据,如果 Redis 里没有再从数据库加载。这样用户修改昵称后,下次登录就会重新写入缓存,逻辑很干净。
密码存储方面,一定不能用明文。我用的是 BCrypt 加密,Spring Security 自带的BCryptPasswordEncoder直接可用,加盐逻辑框架已经处理好了,不需要自己造轮子。
3.2 景点信息管理:一个容易被人忽视的图片处理问题
景点管理功能看起来就是常规的 CRUD,但景点信息里有个特殊字段——图片。实际开发中,图片上传是个非常容易埋坑的点。
我先说踩过的坑:最初用 MultipartFile 直接接收图片并保存到本地磁盘的固定目录,然后数据库存一个相对路径。结果部署到服务器上发现,因为用了反向代理,请求图片的 URL 写的是/upload/xxx.jpg,但代理规则没把/upload路径转发到应用服务,导致图片全部 404。
后来我换了一套方案:图片上传接口统一走/api/file/upload,上传后返回完整的可访问 URL,前端直接把这个 URL 作为图片地址存库。这样无论是本地开发、测试环境还是正式部署,只要配置好静态资源映射和代理规则,图片都不会路径错乱。
还有一个小技巧:景点封面图我建议在存储空间允许的情况下保留原始图和缩略图两个文件,列表页用缩略图(几百 KB),详情页用原图,页面加载速度能提升一个档次。
3.3 评论与评分模块:防止恶意刷评的思路
评论和评分是用户产生内容的核心入口,直接影响推荐算法的数据质量。这块需要考虑两个问题:评分数据如何与景点综合评分联动,以及如何防止恶意刷分。
评分联动逻辑不复杂:用户对某个景点打 1-5 分,系统需要更新该景点的平均评分。这里我用了重算而非累加的策略——每次新增评分后,直接查询该景点的所有评分计算平均值,更新景点表的 rating 字段。
@Transactional public void addComment(Comment comment, int score) { // 1. 保存评论 commentMapper.insert(comment); // 2. 记录用户行为 behaviorService.record(comment.getUserId(), comment.getScenicId(), "COMMENT"); // 3. 重算景点评分 Double avgScore = commentMapper.selectAvgScoreByScenicId(comment.getScenicId()); scenicMapper.updateRating(comment.getScenicId(), avgScore); // 4. 删除景点缓存,保证数据一致 redisTemplate.delete("scenic:detail:" + comment.getScenicId()); }防刷的逻辑我做得比较轻量:同一个用户对同一个景点只能评论一次,数据库层面加了(user_id, scenic_id)唯一索引,代码层面再做一次前置校验。另外,评论内容做了简单的关键词过滤,虽然不如第三方的内容审核服务强,但基本的违规词拦截是没问题的。
3.4 搜索与筛选:别一上来就上 Elasticsearch
很多同学一看到"搜索"功能就想到 Elasticsearch,但这里我要泼一盆冷水。对于一个中小规模的旅游景点推荐系统,数据量撑死几千条,MySQL 的 LIKE 查询完全够用,引入 ES 反而徒增部署复杂度。
我的搜索实现是两张方案并行:
- 简单搜索:使用 MyBatis-Plus 的 LambdaQueryWrapper,对景点名称做模糊匹配,再辅以地区、分类、评分区间的筛选条件
- 高级筛选:前端通过多个参数组合查询,后端动态拼接 SQL,用 QueryWrapper 的
and条件组合
public PageResult<Scenic> searchScenic(ScenicQuery query) { LambdaQueryWrapper<Scenic> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(query.getName())) { wrapper.like(Scenic::getName, query.getName()); } if (StringUtils.hasText(query.getLocation())) { wrapper.eq(Scenic::getLocation, query.getLocation()); } if (query.getCategoryId() != null) { wrapper.inSql(Scenic::getId, "SELECT scenic_id FROM t_scenic_category WHERE category_id = " + query.getCategoryId()); } if (query.getMinRating() != null) { wrapper.ge(Scenic::getRating, query.getMinRating()); } wrapper.orderByDesc(Scenic::getRating); return scenicMapper.selectPage(new Page<>(query.getPage(), query.getSize()), wrapper); }这段代码想说明一个经验:能用 QueryWrapper 解决的搜索不要拆分单独的搜索服务。只有当你的搜索场景变成全文检索、需要分词和语义匹配时,才值得考虑引入专业的搜索中间件。
4. 推荐算法的工程落地
4.1 协同过滤推荐:从理论公式到实际代码
推荐模块是整个系统的技术亮点,也是你们写论文时最能讲故事的地方。我采用的是基于用户的协同过滤(UserCF)为主、内容相似度匹配为辅的混合推荐策略。
UserCF 的核心思路:找到与当前用户兴趣最相似的一批用户,把他们喜欢而当前用户没接触过的景点推荐过来。计算步骤如下:
- 构建用户-景点评分矩阵
- 计算用户间的相似度(皮尔逊相关系数或余弦相似度)
- 找出 Top-N 相似用户
- 聚合这些用户偏好的景点,排除当前用户已消费的景点
- 按预测评分排序,输出推荐列表
理论讲完了,说说代码怎么落地。用 Python 的 pandas 或 sklearn 写推荐算法很方便,但在这个项目里,算法必须用 Java 实现,否则与 Spring Boot 工程无法无缝集成。
public List<Scenic> recommendByUserCF(Long userId, int limit) { // 1. 获取当前用户行为(评分、收藏、浏览) List<ScenicBehavior> behaviors = behaviorMapper.selectByUserId(userId); Map<Long, Double> userPrefs = buildPreferenceMap(behaviors); // 2. 找到相似用户(这里用简化版余弦相似度) Map<Long, Double> similarityMap = new HashMap<>(); List<Long> allUserIds = behaviorMapper.selectAllUserIds(); for (Long otherId : allUserIds) { if (otherId.equals(userId)) continue; List<ScenicBehavior> otherBehaviors = behaviorMapper.selectByUserId(otherId); Map<Long, Double> otherPrefs = buildPreferenceMap(otherBehaviors); double similarity = cosineSimilarity(userPrefs, otherPrefs); similarityMap.put(otherId, similarity); } // 3. 取TopN相似用户 List<Map.Entry<Long, Double>> topUsers = similarityMap.entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .limit(10) .collect(Collectors.toList()); // 4. 聚合推荐候选 Map<Long, Double> candidateScores = new HashMap<>(); for (Map.Entry<Long, Double> entry : topUsers) { List<ScenicBehavior> otherBehaviors = behaviorMapper.selectByUserId(entry.getKey()); for (ScenicBehavior behavior : otherBehaviors) { if (userPrefs.containsKey(behavior.getScenicId())) continue; candidateScores.merge(behavior.getScenicId(), entry.getValue() * behavior.getRating(), Double::sum); } } // 5. 排序取 TopLimit return candidateScores.entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .limit(limit) .map(e -> scenicMapper.selectById(e.getKey())) .collect(Collectors.toList()); }实际运行中这个算法的性能瓶颈在第二步——遍历所有用户计算相似度。当用户规模到一万以上,每次推荐都全量扫描是不现实的。我的缓解方案是把用户相似度计算做成定时任务,每天凌晨离线计算一次,结果存到 MySQL 的t_user_similarity表里,在线推荐时直接查表取 Top-N,响应时间从几百毫秒降到几十毫秒。
4.2 解决推荐冷启动问题的组合拳
协同过滤有个天生缺陷:新用户没有行为数据,无法计算相似度;新景点没有用户评分,无法被推荐。这个项目里用两种方式弥补。
新用户冷启动,用的是热门兜底策略。用户首次登录时直接推荐平台上评分最高、收藏最多的前 20 个景点,同时给用户一个兴趣选择页,让用户勾选感兴趣的景点分类(自然风光、人文古迹、主题乐园等)。选完后立即生成一批带分类标签的景点推荐填充内容,等于用主动反馈的方式快速积累用户偏好。
新景点冷启动,用的是内容相似度补充。每个景点落库时管理端会打上分类、地区、标签等属性,新景点与已有的热门景点做属性匹配,如果与某一类热门景点高度相似,就在该类的推荐池里占一个坑位。这样新景点也能在特定分类下获得一定曝光,有机会积累第一批真实用户反馈。
需要注意的是,推荐模块的测评不能只靠感觉。我在后台接了一个简单的埋点方案:用户在推荐位点击了某个景点,会记录一条behavior_type=CLICK的行为日志。每周统计推荐位的点击率,如果某个位置的点击率持续走低,就会调整推荐算法排序策略或者增加随机扰动。
4.3 推荐结果的排序与解释
纯协同过滤算出来的分数是"计算分",如果直接按分数排序展示,往往会出现推荐列表里全是同质化景点的情况——用户看了三个古镇,系统就推荐八个古镇,看得人审美疲劳。
我的解决方式是在排序阶段加入多样性因子:候选景点按分类分组,同一分类下最多取两个景点,然后再拼接成一个推荐列表。这个策略实现成本低,但对用户体验的提升非常明显。
至于推荐理由,热门产品喜欢给"猜你喜欢"加一行解释文本,我这个项目也做了简化版:推荐列表里给每个景点带上推荐依据标签,比如"与你收藏的西湖相似""和你兴趣相投的用户喜欢这里"等。这些标签在计算阶段顺手就能生成,一句代码的事,但用户的感知完全不同。
5. 常见问题与排查经验实录
5.1 开发中遇到的那些数据库问题
第一个典型问题是分页查询数据量大后变慢。景点表加上分类关联表,联表查询几千条数据时,PageHelper 生成的分页 SQL 在count计数上很耗时。解决办法是给景点表加上合适的索引,并且把查询场景拆开——列表页只查景点主表字段,分类名称通过单独的缓存或冗余字段解决,不参与联表查询。
第二个问题是事务边界划错。我在评论模块一开始把"插入评论 + 更新景点评分 + 记录行为日志"放在同一个事务方法里,逻辑上没问题,但后来发现如果行为日志表写入失败,会导致用户评论也回滚。后来重新分了层:评论和评分更新是一个事务,行为日志写入是另一个事务,而且行为日志允许失败不影响主流程。因为行为日志是推荐算法的辅助数据,丢几条不会造成核心功能故障,但用户的评论是绝对要保证的。
程序设计里有一个朴素的道理:不同类型的数据,可靠性要求是不一样的,事务边界要跟着业务重要性走,而不是什么都往一个事务里塞。
5.2 N+1 查询与性能优化
MyBatis-Plus 用起来方便,但写循环查库特别容易踩 N+1 的坑。比如给景点列表填充评论数量:
// 错误示范:循环中去查库 for (Scenic scenic : list) { scenic.setCommentCount(commentMapper.countByScenicId(scenic.getId())); }这条代码一旦景点数量上百,数据库就要执行上百次查询,接口必然超时。正确做法是用一次IN查询把所有的评论数量一次性取出来:
List<Long> scenicIds = list.stream().map(Scenic::getId).collect(Collectors.toList()); List<Map<String, Object>> counts = commentMapper.groupByScenicIds(scenicIds); Map<Long, Long> countMap = counts.stream() .collect(Collectors.toMap(m -> (Long) m.get("scenic_id"), m -> (Long) m.get("cnt"))); list.forEach(s -> s.setCommentCount(countMap.getOrDefault(s.getId(), 0L)));我做压测的时候发现,景点列表接口从 1.2 秒优化到 300 毫秒,就是靠这类批量查询的替换。这项改造做完,心里舒服多了。
5.3 缓存穿透与数据一致性
Redis 缓存的经典三个坑,我全踩了一遍:
- 缓存穿透:查询一个不存在的景点 ID,缓存里没有,数据库里也没有,请求每次都打到数据库。解决办法是缓存空值并设置短 TTL,或者在查询入口做参数校验,过滤掉明显不合理的 ID。
- 缓存击穿:某个热点景点的缓存过期瞬间,大量请求同时打到数据库。用互斥锁解决,只有拿到锁的线程去数据库查询并重建缓存,其余线程等待后直接从缓存获取。
- 缓存雪崩:大量缓存同时过期,数据库压力瞬间飙升。给 TTL 加一个随机偏移量,避免同批 key 在同一时刻集体失效。
数据一致性上,我的代码里在"更新数据库后主动删除缓存"这个节点用心做了逻辑:对评论和评分类数据,采用 Cache Aside 模式,数据库更新成功后删除对应 Redis key,下次查询时重新加载。这个方案不是强一致的,但在这个业务场景下完全够用,最多出现几秒钟的数据不一致窗口,用户感知不到。
5.4 前后端联调的细节问题
前端联调阶段最容易出问题的不是功能逻辑,而是跨域和请求格式。
跨域我直接用了 Spring Boot 的 CORS 配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意不要全用*允许所有源,尤其是不需要携带 Cookie 的接口。如果用allowCredentials(true)就必须用allowedOriginPatterns而不能用allowedOrigins("*"),这个是 Spring 的硬性规定,能省不少排查时间。
请求格式方面,前端 POST 请求如果发送的是 JSON,后端必须用@RequestBody接收,并且前端要设置Content-Type: application/json。前后端联调时最常见的 415、400 错误,八成都是这个原因。
5.5 一段特殊的排查经历
最后分享一个真实遇到的诡异 Bug。开发环境一切正常,部署到测试服务器后,用户登录接口随机超时。查了半天发现,是服务器的时钟比真实时间快了几分钟,导致 JWT 的iat声明(签发时间)比服务器当前时间还晚。JWT 做校验时发现 token 签发时间在未来,直接判定无效,于是认证逻辑在"校验通过"和"校验失败"之间随机抖动。
这个问题极有迷惑性,因为本地开发机器和服务器的时间是同步的,根本复现不了。最后解决方式是在服务器上做时间同步,同时把 JWT 的时间校验做一个 5 分钟的容忍窗口。这个 Bug 提醒我,分布式环境里,时间和时钟同步是一个容易被忽略但会造成诡异故障的细节。
写在最后:几点实际体会
整个项目从设计到完成,前后花了一个多月,最大的感受是:管理系统并不简单,难点不在于增删改查本身,而在于各种模块之间的耦合与取舍。
有几点经验想传递给做类似项目的同学。第一,数据库设计和索引规划一定要在一开始就想好,后面改表牵扯的链路太长,代价太大。第二,推荐模块的价值在于持续迭代,不要想着一步到位,先跑通最简单的协同过滤,再逐步加权重、加多样性、加缓存,每一步都能看到效果变化。第三,技术选型一定结合数据量做判断,几千条数据上 Elasticsearch 或者搞复杂的分布式架构,只会给自己的项目加负担。
最后再分享一个小技巧:做这类系统的过程中,给自己养成记录"踩坑日记"的习惯。我上面写的这些问题,大多数是在项目结束后复盘记录的。这些记录不只是为了写论文,它们在实际面试中是最有说服力的项目经验。面试官问"遇到什么困难"的时候,从这些真实的排查经历里挑一段讲,比背八股文有说服力得多。