1. 项目拆解与技术选型
1.1 先搞清楚这个系统要干什么
先把标题翻译成人话:这是一个基于 Spring Boot 的 Web 旅游推荐系统,编号 11757。这类系统在大学里出现的频率非常高,因为它同时覆盖了后端开发、数据库设计、推荐算法、前端交互几个大块,非常适合用来练手或者当作毕业设计。但说白了,它的核心就两件事:让用户能浏览、搜索、预订旅游相关的资源(景点、酒店、线路),然后系统能根据用户的行为数据,给每个用户推一批“他可能更喜欢”的内容。
我见过很多同类项目,最常见的毛病是功能堆了一堆,但推荐模块形同虚设,要么写死几条热门数据,要么干脆不实现。真正能称为“推荐系统”的项目,至少得具备这么几个能力:
- 能够记录用户行为(浏览、收藏、下单、评分)。
- 有一套推荐逻辑,不依赖写死的排序。
- 推荐结果会随用户行为变化,而不是永远不变。
- 后台能维护景点、线路、轮播图、订单等基础数据。
也就是说,用户端和管理端必须同时存在。用户端的核心是“找”和“订”,管理端的核心是“维护”和“分析”,推荐模块夹在中间,把两端串起来。本文后面所有内容都围绕这个定位展开。
1.2 技术选型,我为什么这么定
Spring Boot 本身只是个基础框架,真正决定项目质量的,是你选了哪些组件配合它。表 1 是这套系统最稳的组合。
| 层次 | 选型 | 核心理由 |
|---|---|---|
| 后端基础 | Spring Boot 2.7.x | 稳定,资料多,适合绝大多数环境 |
| ORM 框架 | MyBatis-Plus | 单表操作零 SQL,复杂查询不受限制 |
| 数据库 | MySQL 8.0 | 性价比最高,开发和部署都方便 |
| 缓存 | Redis | 做热门榜单、验证码、Session 共享 |
| 前端 | Vue 2 + Element UI | Vue 2 生态成熟,入门资料多,团队协作效率高 |
| 推荐算法 | 基于内容的协同过滤 | 不依赖用户量,前期也能跑出效果 |
有人可能会问,为什么不直接用 JPA 或者 MyBatis 原生?我个人的经验是:MyBatis-Plus 在单表 CRUD 的效率上远超原生 MyBatis,但遇到多表关联、动态 SQL 时又不会像 JPA 那样绕,属于折中的最优解。Spring Boot 2.7.x 配合 MyBatis-Plus 3.5.x 是当前最不容易踩坑的组合。
推荐算法的选择也很关键。基于物品的协同过滤(Item-CF)在旅游场景有个天然问题:如果两个景点从来没被同一个人消费过,它们之间就永远不会产生相似度,说白了就是稀疏性问题。所以我在这套系统里用的是“内容特征 + 行为加权”的混合策略,这个下一章详细展开。技术选型不需要追新,稳定、能落地、能说清楚原理,才是这类项目最需要的。
2. 数据库设计与核心表结构
2.1 基础表设计思路
数据库设计决定了后面所有功能的开发效率。这套系统里,我建议至少规划以下几张核心表,而不是把所有字段都塞进一张大表里。
用户表sys_user:保存账号、密码、昵称、头像、手机号。密码必须加密存储,推荐用 BCrypt,不要去 MD5 加盐,因为 Spring Security 自带 BCrypt 支持,安全级别也够。
景点表t_scenic:这是推荐系统最重要的内容表,字段越完整,推荐效果越好。除了名称、介绍、图片、地址、开放时间以外,一定要有“类型标签”字段,比如“自然风光”“历史文化”“亲子游乐”“网红打卡”,推荐模型全靠这个字段计算相似度。
线路表t_route:关联多个景点,形成行程方案。表里可以加一个 schedule 字段用 JSON 格式存储每天安排,这样做线路展示时很灵活。
订单表t_order:记录用户买的门票、酒店、线路等商品,状态字段至少包含待支付、已支付、已取消、已完成。这张表既是业务数据,也是推荐系统的重要数据来源——用户买过什么,比用户看过什么更能反映意图。
除了这些基础表,我还额外设计了收藏表t_favorite和打分表t_score。收藏表解决“用户喜欢什么”,打分表解决“用户有多喜欢”。推荐算法里这两张表的价值远高于浏览记录。
2.2 推荐模块的存储设计
很多人容易忽略一个点:推荐系统不是现算现跑,而是要把“用户对物品的偏好”以某种结构存下来,否则每次请求都全表扫描,数据库根本扛不住。我这里引入了两张推荐专用表。
第一张是用户偏好表t_user_pref,字段包括 user_id、tag_name、pref_score。pref_score 表示该用户对某个标签类型的偏好强度。每当用户产生行为,就给对应标签加分。举个例子,用户收藏了一个“自然风光”的景点,那么该用户对“自然风光”的偏好就会 +2;如果只是浏览,则 +0.5。
第二张是相似景点表t_scenic_sim,字段包括 scenic_id、similar_scenic_id、sim_score。这个表不需要每次实时算,可以用定时任务每天凌晨更新一次,白天推荐时只读数据就行。
用这种“预计算”思路,推荐的性能和效果都能兼顾。实时计算只用于用户刚产生行为后的那次“即时刷新”,其余时间走缓存和预计算表。
3. 推荐算法从零落地
3.1 用户行为数据的采集与权重
推荐算法的核心输入,是用户行为。一个用户在一个景点页面上多停留 10 秒,和这个用户直接下单买门票,表达的兴趣强度完全不同。我设计了一套行为权重体系,如表 2 所示。
| 行为类型 | 权重值 | 触发条件 |
|---|---|---|
| 浏览 | 0.5 | 进入详情页停留超过 5 秒 |
| 搜索点击 | 0.8 | 搜索结果中点击某景点 |
| 收藏 | 2.0 | 点击收藏并成功 |
| 评分 | 3.0 * 分值 | 打分时按实际分值加权 |
| 下单 | 5.0 | 支付成功后触发 |
为什么要区分这么细?因为如果所有行为权重一样,那么“手滑点了看一眼”和“掏钱购买”会被同等对待,推荐结果就会失真。下单行为的权重设为浏览的十倍,能让推荐结果快速响应用户的真实意图。
这套逻辑写成一个前端埋点接口,前端在页面滚动停留、点击、收藏、支付完成时,分别调用一次行为上报接口。后端拿到行为类型和景点 ID 后,异步更新t_user_pref表中的标签偏好分数。
3.2 相似度计算怎么写到代码里
基于内容特征计算景点相似度,最常用的办法是“标签构造向量”。假设我有五个标签:自然风光、历史文化、亲子游乐、网红打卡、美食购物。某个景点的特征向量就是[1, 0, 1, 0, 0],表示它属于自然风光和亲子游乐。两个景点的相似度就用余弦相似度计算:
sim = (A · B) / (|A| * |B|)举个例子,景点 A 向量[1, 1, 0, 0, 0],景点 B 向量[1, 0, 1, 0, 0],分子是 1,分母是 sqrt(2) * sqrt(2) = 2,所以相似度是 0.5。这个值越高,表示两个景点在标签维度上越接近。
在代码里,我建议把向量存入 MySQL 的 JSON 字段或者单独一个标签表,然后在定时任务里两两计算相似度,过滤掉相似度低于 0.3 的结果,写入t_scenic_sim。代码结构大致是这样:
@Component @Slf4j public class ScenicSimilarityTask { @Resource private ScenicService scenicService; @Resource private ScenicSimService scenicSimService; @Scheduled(cron = "0 30 2 * * ?") // 每天凌晨 2:30 执行 public void computeSimilarity() { List<Scenic> allScenic = scenicService.list(); for (int i = 0; i < allScenic.size(); i++) { for (int j = i + 1; j < allScenic.size(); j++) { double score = cosineSimilarity( allScenic.get(i).getTagVector(), allScenic.get(j).getTagVector()); if (score >= 0.3) { // 双向写入相似关系 scenicSimService.save(new ScenicSim(i.getScenicId(), j.getScenicId(), score)); scenicSimService.save(new ScenicSim(j.getScenicId(), i.getScenicId(), score)); } } } } private double cosineSimilarity(List<Integer> vectorA, List<Integer> vectorB) { // 此处验证空间向量的实现 } }这里tagVector在落地时可以直接从数据库查出来拼成列表,不需要真正引入分布式计算框架。对一个中小型旅游网站来说,景点数量通常几百到几千,双重循环加余弦公式足够用了。
3.3 冷启动与兜底策略
再好的推荐系统也有冷启动问题:新用户没有行为数据,新景点没有打分记录,这时候推什么?我采用三级兜底策略。
第一级,新用户直接推“热门榜”。热门榜不是简单按浏览量排序,而是用一个综合分数:浏览权重 + 收藏权重 + 订单权重,计算出一个热度值。因为游客在还没登录时点进来,系统给的是最热门的推荐,这个阶段没有个性化。
第二级,用户有一点行为数据后,就从行为中提取偏好标签,召回该类标签下相似度 TopN 的景点。比如用户收藏过“历史文化”类景点,系统就推送其他“历史文化”的景点,优先推相似度最高的。
第三级,用户行为数据足够多了(比如行为记录超过 20 条),就混合两种策略:50% 来自用户偏好标签召回,30% 来自相似景点扩展,20% 来自热门兜底。这样既保证个性化,又不会因为算法的“信息茧房”把所有内容都推成同一类。
冷启动这步我踩过不少坑,最有价值的一条经验是:过滤掉用户标签权重中占比过低的标签,比如用户只有一条行为记录,权重全加在“美食购物”上,不代表他就是美食爱好者。只有当某个标签的爱好分数超过全局平均偏好分数的两倍时,才判定为“强偏好”。
4. 核心模块的实现细节
4.1 登录校验与统一异常
Web 系统最基础也最重要的部分是登录校验。我用 Spring Security + JWT 做认证,没有用传统 Session 模式,原因是 JWT 天然适合前后端分离,服务端不需要存 Session,就更方便做横向扩展。用户登录成功后,后端返回一个 JWT token,前端存在本地,每次请求带在 Authorization 请求头里。
登录接口示例:
@PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { // 校验验证码 String code = redisUtil.get("captcha:" + dto.getUuid()); if (!dto.getCode().equalsIgnoreCase(code)) { return Result.error("验证码错误"); } // 校验账号密码 User user = userService.findByUsername(dto.getUsername()); if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.error("账号或密码错误"); } // 生成 token String token = JwtUtil.createToken(user.getId(), user.getRole()); return Result.success(token); }这里有几个实操教训:BCrypt 校验的两个参数别传反了,第一参数是明文密码,第二参数是加密后的密文;验证码必须一次性使用,校验完就删掉;JWT 的过期时间不要设太长,我一般设为 2 小时,配合刷新 token 使用,用户体验和安全都能兼顾。
统一异常处理这块,我建议每个项目都建一个GlobalExceptionHandler,把业务异常和未知异常分别处理。否则一旦现场环境中某个接口抛了个空指针,前端拿到一堆英文堆栈提示,用户直接懵。统一处理成{code: 500, msg: "服务器开小差了,请稍后再试"},至少体验是合格的。
4.2 景点与线路模块
景点模块的难点不在列表页,而在详情页。详情页要包含景点介绍、图片集、评论列表、关联线路、相似景点推荐五块内容,如果是新手很容易写出 5 个查询接口,前端挨个调,白白浪费网络开销。我建议用 Controller 聚合一次返回,后端只提供一个接口,内部查 5 张表,组装成一个 DTO 返回。
@GetMapping("/detail/{id}") public Result getDetail(@PathVariable("id") Integer id) { ScenicDetailVO vo = new ScenicDetailVO(); vo.setScenic(scenicService.getById(id)); vo.setComments(commentService.listByScenic(id)); vo.setRoutes(routeService.listByScenic(id)); vo.setSimilar(scenicSimilarService.topSimilar(id, 5)); // 记录浏览行为,用于推荐 asyncBehaviorService.record(CurrentUser.getId(), id, "browse"); return Result.success(vo); }代码里asyncBehaviorService.record是异步方法的调用,这样不会因为记录行为造成接口响应变慢,我这里使用的做法是直接把记录丢一个异步线程池处理。注意 Spring Boot 里开启@EnableAsync后,async 方法一定要放在独立 Bean 中,否则不生效,这是一个非常隐蔽的坑。
线路模块可以设计成“行程规划”形式:用户选定出发地、游玩天数、预算金额、偏好类型,系统根据这些条件自动组合一条线路。实现时我用的是贪心策略:先按用户偏好标签找出目标景点池,然后按地理位置距离逐个添加,保证每天的行程不会太赶。当然这不是最优解,但对一个演示项目来说,远比写死几条线路要显得专业。
4.3 订单与支付流程
订单模块要考虑的不只是 CRUD,还有一个很关键的点:库存。比如某个景点的票存量只有 100 张,如果两个用户同时下单,系统必须保证不超卖。我用 Redis 的DECR命令实现了库存扣减,比数据库行锁性能高得多。
流程是这样的:用户提交订单时,后端先查 Redis,看景点 ID 对应的库存 key 是否大于 0,然后用DECR减一。如果减出来是负数,说明超卖,立即回滚并提示“该景点今日已售罄”。支付成功的回调里再把订单状态从未支付改为已支付。
超卖的问题,本质上就是“先校验再扣减”两个步骤不是一个原子操作。用DECR一次性完成扣减,就是为了避免竞态。数据库里的实际库存可以设一个允许为负数的字段兜底,定时任务每小时把 Redis 的数据同步回 MySQL,两边保持一致。
订单这块的安全底线是:坚决不用前端传来的金额。金额一律从数据库的商品表读取,传给支付接口时也重新算一遍。我在其他项目里见过有人直接在订单表里取一个totalAmount字段,然后前端把价格改了,空子就是这么留下的。
4.4 推荐接口返回给前端
推荐接口的最终形态是这样一个接口:传入用户 ID(或者从 token 解析),返回一组景点或线路。
@GetMapping("/recommend") public Result recommend(@RequestParam(defaultValue = "10") Integer limit) { Integer userId = CurrentUser.getId(); List<RecommendVO> list = recommendService.recommendByUser(userId, limit); return Result.success(list); }recommendByUser的内部逻辑,对应之前讲的三个层级策略。代码里的顺序大致是:
- 查询用户偏好标签 Top3。
- 用这些标签去召回候选景点,按标签匹配数降序排序。
- 在候选中剔除用户已经下单或明确不感兴趣的景点。
- 如果候选不足 5 个,用热门榜补位。
- 最终结果写入 Redis 缓存(键为
recommend:user:{userId},过期时间 30 分钟)。
这里要强调一下过滤功能。如果用户已经买过某景点的票,还持续收到该景点的推荐,体验会很糟糕。所以在推荐 SQL 里我用了一个NOT IN子查询,把订单表里存在记录的内容排除掉。
有的项目会把推荐结果直接输出成 JSON 数组给前端,有的则会要求返回分页结构。我建议用传统 Page 结构,这样前端扩展性更强,换成小程序或 App 时改起来也方便。
5. 部署、演示与常见问题
5.1 开发环境一键启动配置
这套系统的开发环境建议这么配:
JDK 1.8 或 11,Maven 3.6+,MySQL 8.0,Redis 6.x,Node.js 14+。项目启动前需要初始化数据库,我习惯把初始化脚本放在项目根目录的sql文件夹下,里面包含建表语句和演示数据。启动顺序是先启动 Redis,再启动 MySQL,然后启动后端。
后端application.yml里必须改两个地方:
spring: datasource: url: jdbc:mysql://localhost:3306/travel_rec?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你本机的数据库密码 redis: host: localhost port: 6379如果你用的是 Null 值,密码不填也行;但如果密码填错,启动时数据源初始化会报Access denied,这个要留意。启动后端后,访问本地的http://localhost:8080/api/doc.html就能看到接口文档,我集成的是基于 Knife4j 的自动接口文档,调试起来比 Postman 还要直观。
前端启动命令是:
npm install npm run serve等看到App running at Local: http://localhost:8081的提示,就算成功了。注意如果后端端口是 8080,前端端口是 8081,需要在前端项目下找到.env.development配置文件,把VUE_APP_BASE_API设置成http://localhost:8080/api。这一步漏了,前端所有请求都会 404。
5.2 演示数据准备
很多初学者把项目跑起来后发现首页空空如也,就以为系统出 bug 了。其实是因为数据库里没有演示数据。我强烈建议初始化脚本里写入至少 20 个景点、5 条线路、3 个酒店、若干评论和订单数据。没有数据,推荐系统等于巧妇难为无米之炊。
演示账号也建议直接给两个:
| 角色 | 账号 | 密码 |
|---|---|---|
| 管理员 | admin | admin123 |
| 普通用户 | user | user123 |
管理员登录后跳到后台管理,能看到用户管理、景点管理、线路管理、订单管理、评论管理、轮播图管理。普通用户登录后跳到前台,能看到推荐首页、景点列表、线路规划、个人中心、我的订单。
演示数据有个小技巧:景点图片不要用本地文件,直接用外链图床,比如https://xxx.com/photo/1.jpg的形式。本地文件路径一旦部署到服务器,图片就会全挂,外链虽然受网络影响,但胜在省事,一个毕业设计完全够用。
5.3 常见问题与排查实录
我把开发这套系统时最常遇到的 6 个问题列在表 4 里,方便你遇到时直接对号入座。
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
后端启动报Failed to configure a DataSource | 未配数据源或 MySQL 未启动 | 检查application.yml和 MySQL 服务状态 |
| 前端登录后请求接口全 401 | JWT 过期或未传请求头 | 检查 token 有效期,确认前端请求拦截器加了Authorization头 |
| 验证码一直报错 | Redis 没启动或 key 过期时间太短 | 检查 Redis,确认验证码过期时间 ≥ 60 秒 |
| 列表页数据为空 | 演示数据未导入 | 执行sql/init.sql |
| 中文乱码 | 数据库字符集不是 utf8mb4 | 建库时指定CHARACTER SET utf8mb4 |
| 推荐接口返回固定数据 | 用户行为权重未更新 | 检查行为上报接口是否被正确调用,Redis 缓存是否未失效 |
这里有几个排查技巧是文档里通常不会写的。第一个,遇到 401 先看浏览器的 Network 面板,确认请求头里有没有Authorization字段;第二个,验证码接口跟着报错,先redis-cli进命令行,keys *看看 key 是否还在,判断是不是过期了;第三个,推荐接口不更新,先删掉 Redis 里对应 key 再刷新,确认是算法问题还是缓存问题。
我在调试推荐模块时,最喜欢做的一件事是:用管理员账号给用户 user 手动插入 10 条“历史文化”标签的浏览记录,然后刷新推荐页,看看推荐结果是否偏向历史文化类。如果变了,说明行为采集链路通;如果没变,说明问题出现在 Redis 缓存或者行为异步写入那一步。
5.4 性能优化与后续扩展
最后聊几个性能优化方向。系统刚跑起来时 QPS 不高,单机完全扛得住,但不代表可以不考虑优化。我建议至少做三件事。
第一,首页景点列表接口是最大热点,单次可能查 10 张表。我会在 Redis 里缓存列表 JSON 数据,过期时间 10 分钟。别人问你怎么优化性能,这一步就是最直观的加分项。
第二,数据库索引不能省。t_order表的user_id、t_comment表的scenic_id、t_scenic_sim表的两列关联索引都要建。没有索引,数据量到几万条时接口延迟马上暴露。
第三,图片走 CDN。本地图片最大的问题不是存储,而是带宽。某次模拟测试时同一张图片被几千个用户同时请求,带宽直接被打满。换成 CDN 后这个问题瞬间消失。
这个项目如果要继续扩展,可以从两个方向入手。一是加入实时天气接口,根据目的地天气动态调整推荐排序——下雨天优先推室内场馆,晴天推自然景区,这能让推荐结果再上一个台阶。二是接入第三方地图 API,在行程规划里加入驾车时间和步行时间,做一个真正能用的“智能行程助手”。这两个方向都踩在推荐系统的核心业务上,延伸出来都是很好的升级点。
我个人在开发这类系统时最大的感受是:推荐系统本身并不难,难的是把用户行为、数据存储、算法模型、后端接口串成一个闭环。如果你也在写类似项目,先别急着堆功能,把“用户产生行为 → 行为更新偏好 → 偏好影响推荐 → 推荐反馈给用户”这条链路跑通,后面的功能都是围绕这条主线的自然延伸。