ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue3城市周边游推荐系统设计与混合推荐实战

SpringBoot+Vue3城市周边游推荐系统设计与混合推荐实战 1. 项目概述1.1 这个系统的核心价值是什么这段时间在做城市周边游推荐系统前后端分离架构用SpringBoot 2.7 Vue 3。先说结论这个系统不是做那种大而全的旅游平台而是聚焦到城市周边这个场景解决的是用户在周末、小长假去哪玩、怎么玩、怎么规划路线的问题。整个项目花了大概两周时间从需求分析到上线部署完整走了一遍。做这个项目的起因很现实。现在的旅游App像携程、去哪儿面向的是异地长线游用户你要查个周边游景点反而很费劲——一堆机票酒店信息占据页面周边三五十公里的短途游反而没有专门的入口。美团上有周边游入口但偏餐饮小红书上有推荐内容但没法直接做路线规划和预订。这就是机会点做一个专注城市周边游的轻量推荐系统用户可以看景点、查攻略、看路线、收藏喜欢的目的地。整个系统适合两类人参考学习一类是准备做SpringBoot课程设计或者毕业设计的同学项目代码完整、前后端分离、功能模块清晰另一类是刚开始接触推荐系统、想知道推荐逻辑怎么落地的新手开发者。你可以直接运行源码看效果也可以按着我下面的思路去扩展自己的功能。1.2 项目技术栈和整体架构先交代下技术选型。后端用的是SpringBoot 2.7.5这是目前最稳定的2.x版本基于JDK 8开发Maven管理依赖。持久层用的MyBatis-Plus 3.5.2不用自己写繁琐的XML映射。数据库选了MySQL 8.0缓存用Redis 5.0存热门景点数据和用户会话。前端是Vue 3 Element Plus Axios配合Vite构建工具做开发调试。这套组合在2024年的Java技术圈算是非常主流了尤其SpringBootMyBatis-PlusVue 3的组合基本就是中小型Web项目的标配。选型时避开了SpringCloud全家桶原因很简单——单机部署的小项目不需要微服务那套复杂度引入Nacos、Feign反而把问题复杂化。模块拆分开但代码在一个工程里后面想升级成微服务架构也很方便。项目整体分了三个层次前端展示层负责页面交互和推荐结果展示后端业务层负责推荐算法、用户行为收集、路线规划这些核心逻辑数据层负责景点、用户、收藏、路线的存储。推荐引擎单独拆了个模块这也是整个系统里最有技术含量的部分。2. 推荐系统的核心设计思路2.1 为什么选择混合推荐策略推荐系统这块我调研了很多现有方案。大厂的推荐靠的是海量用户行为数据和深度模型我们一个小型项目不可能走这条路。但也不能做得太简单上来就按国家5A级景区排序那种那就不叫推荐系统了叫景点列表页。最终采用了混合推荐策略三层递进第一层是基于内容的相似推荐。核心逻辑是计算当前用户喜欢或者正在查看的景点的相似景点。相似度怎么算我给每个景点打了一组标签比如自然风光、亲子游、徒步登山、历史古迹、城市公园。两个景点之间计算标签的余弦相似度取TopN作为相似推荐结果。举个例子用户正在看奥林匹克森林公园标签是城市公园亲子游自然风光系统就会把朝阳公园、南海子公园、温榆河公园推荐上来因为它们标签向量很接近。第二层是基于用户的协同过滤。用户A和用户B收藏/浏览的景点重叠度高系统就把用户B收藏过而A没看过的景点推荐给A。这里用的是最简单的皮尔逊相关系数计算用户相似度不引入复杂计算框架。数据量小的时候效果很好用。第三层是热度兜底推荐。对于没有行为记录的新用户直接推荐当前城市范围内浏览量和收藏量最高的Top10景点保证冷启动场景下用户不会面对空白页面。三层结果最后加权融合得分公式最终得分 0.35 * 内容相似度 0.45 * 协同过滤得分 0.2 * 热度得分权重参数是我试了几个组合调出来的内容相似度和协同过滤在用户有行为时确实更准热度保证基本盘。2.2 数据建模和表结构设计说完了算法再看数据模型。整个系统的表结构围绕推荐流程设计一共六张核心表景点表scenic_spot景点基础信息包含景区名称、所在城市、区域、详细地址、景点介绍、标签逗号分隔存储、封面图URL、经度纬度、联系电话、开放时间。用户表sys_user用户基本信息账号密码、昵称、头像、手机号。密码用MD5加盐存储登录用JWT签发Token这部分没什么特别的标准SpringSecurity的密码处理方式。收藏表user_favorite记录用户收藏的景点。注意这里存了一个create_time字段除了做列表排序还能用于分析用户的兴趣随时间变化。浏览历史表view_history用户每次查看景点详情都会写入一条记录。这是协同过滤最核心的数据来源比收藏行为更密集数据量也大得多。路线推荐表travel_route预设的周边游路线比如顺义亲子一日游怀柔山水自驾两日游每条路线关联多个景点和推荐游玩时长。景点评分表rating_info用户可以对去过的景点打1-5分。这是质量最高的显式反馈数据虽然采集难度大但只要用户打了分推荐准度提升非常明显。但因为没有对接真实支付和核销系统评分数据在测试阶段主要通过模拟灌入。这里有个值得说的设计细节——为什么标签用逗号分隔存储而不是单独建关联表严格来说第三范式要求建中间表但考虑到标签是二维结构维度是标签类型值是标签名而且查询时基本是模糊匹配单独建表反而要两次Join性能也更慢。项目初期为了迭代效率选择了反范式设计把标签冗余在景点表里。当然这带来的问题是后期如果想做标签聚合分析会很吃力但当前阶段影响不大。3. 核心模块的实现细节3.1 用户行为采集与存储推荐系统的关键在数据。不管算法多高级没有用户行为数据就是空中楼阁。项目里我单独封装了一个UserBehaviorService统一处理浏览、收藏、评分三类行为的采集。浏览行为的埋点放在景点详情页的前置路由守卫里。用户点进景点详情时前端先调POST /api/behavior/view接口传参只有两个——景点ID和来源场景是推荐位点进来的、搜索结果点进来的、还是路线详情里点进来的。后端接口拿到参数后先查Redis如果当前用户对该景点的最近浏览记录存在并且时间差不到30分钟就判定为重复浏览直接忽略不落库。这个去重逻辑非常关键否则用户一次详情查看由于前端组件重新渲染可能导致行为数据双写甚至多写后面统计的热度值会有很大失真。收藏行为的采集相对简单。用户点击收藏按钮前端调POST /api/favorite接口。但这里埋了一个重要的细节——当用户取消收藏时同时记录一条unfavorite事件到行为日志表。不要小看这个反向行为信号用户取消收藏往往代表兴趣转移对推荐算法的负反馈价值非常高。我在协同过滤计算权重时一次取消收藏的负向强度约等于三次新增收藏的正向强度。评分数据的采集直接复用景点详情的评价弹窗用户提交评分时走POST /api/rating接口。推荐算法的数据计算是异步的。用户产生行为后接口立即返回前端同时通过Async注解把更新任务丢给线程池去处理。这么设计的原因在于实时计算用户相似度矩阵是O(n²)的复杂度如果同步执行每一次收藏操作背后可能拖垮3-5次数据库查询。异步处理后行为数据入库和推荐结果刷新之间的延迟在测试环境中大概3-5秒用户基本感知不到。如果你要复现这个模块建议重点关注行为数据表的时间字段索引。浏览历史表数据量增长很快一天测试下来可能几万条。我在view_history表的user_id和create_time上建了联合索引查询该用户最近30天浏览记录这类高频操作从全表扫描4-5秒降到了毫秒级。3.2 内容相似度推荐实现基于内容的相似推荐实现难度不高但踩了两个坑。我直接贴上核心代码逻辑public ListScenicSpot recommendByContent(Long userId, int limit) { // 1. 获取用户最近浏览过的N个景点 ListLong recentViewedIds viewHistoryMapper.selectRecentByUser(userId, 5); if (recentViewedIds.isEmpty()) { return hotSpotService.getHotSpots(limit); } // 2. 取出用户最近浏览且收藏过的景点作为种子 ListScenicSpot seedSpots scenicSpotMapper.selectList( new LambdaQueryWrapperScenicSpot() .in(ScenicSpot::getId, recentViewedIds) .last(LIMIT 3)); // 3. 对每个种子景点找相似景点 MapLong, Double scoreMap new HashMap(); for (ScenicSpot seed : seedSpots) { ListScenicSpot candidates scenicSpotMapper.selectByTagLike( seed.getTags().split(,)[0]); // 取最高权重标签 for (ScenicSpot candidate : candidates) { if (candidate.getId().equals(seed.getId())) continue; double similarity cosineSimilarity(seed.getTags(), candidate.getTags()); if (similarity 0.2) { scoreMap.merge(candidate.getId(), similarity, Double::sum); } } } // 4. 按得分排序取TopN return scoreMap.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(limit) .map(entry - scenicSpotMapper.selectById(entry.getKey())) .collect(Collectors.toList()); } private double cosineSimilarity(String tags1, String tags2) { SetString set1 new HashSet(Arrays.asList(tags1.split(,))); SetString set2 new HashSet(Arrays.asList(tags2.split(,))); int intersection 0; for (String tag : set1) { if (set2.contains(tag)) intersection; } if (intersection 0) return 0.0; return intersection / (Math.sqrt(set1.size()) * Math.sqrt(set2.size())); }第一个坑是种子景点的选择。我一开始用的是最近浏览的3个景点作为种子但实测下来效果很差。原因是用户最近看的景点可能只是随意点开并没有真正的兴趣倾向。后来在种子选择前加了一层过滤——优先选最近7天内收藏或评分过的景点作为种子只有这类强行为信号才能代表真实兴趣。如果用户7天内没有收藏行为再去选最近浏览记录。这个改动直接让推荐点击率CTR从11%提升到23%效果非常显著。第二个坑是标签切分。调selectByTagLike时我用的是最高权重标签作为筛选条件但不同景点的标签顺序不一样有的把自然风光放在第一位有的放在最后一位。这会导致候选集漏掉很多相似景点。后来改成拆出所有标签分别查询后去重合并候选集规模翻了两倍多推荐准确性也有了明显提升。3.3 协同过滤和热度推荐落地协同过滤这块我选择了基于皮尔逊相关系数的用户相似度计算。核心步骤是建立用户-景点评分矩阵浏览计1分收藏计3分评1-5星则直接使用评分值然后计算用户之间的皮尔逊相关系数。public ListScenicSpot recommendByCollaborativeFiltering(Long userId, int limit) { // 1. 找到当前用户的行为向量 MapLong, Double userRatings getUserRatingMap(userId); if (userRatings.isEmpty()) return Collections.emptyList(); // 2. 查询所有其他用户 ListUserBehavior allUsers userBehaviorMapper.selectAll(); // 3. 计算相似用户及相似度 MapLong, Double userSimilarityMap new HashMap(); for (UserBehavior other : allUsers) { if (other.getUserId().equals(userId)) continue; double similarity pearsonSimilarity( userRatings, getUserRatingMap(other.getUserId())); if (similarity 0.3) { userSimilarityMap.put(other.getUserId(), similarity); } } // 4. 对相似用户评分过的但当前用户没评分的景点做加权推荐 MapLong, Double recommendScores new HashMap(); for (Map.EntryLong, Double entry : userSimilarityMap.entrySet()) { Long similarUserId entry.getKey(); double similarity entry.getValue(); MapLong, Double similarUserRatings getUserRatingMap(similarUserId); for (Map.EntryLong, Double ratingEntry : similarUserRatings.entrySet()) { Long spotId ratingEntry.getKey(); if (userRatings.containsKey(spotId)) continue; recommendScores.merge( spotId, ratingEntry.getValue() * similarity, Double::sum); } } // 5. 按推荐得分排序 return recommendScores.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(limit) .map(entry - scenicSpotMapper.selectById(entry.getKey())) .collect(Collectors.toList()); }谐音后的经验是这里的皮尔逊相关系数计算要注意用户共同评分过的景点集合过小的问题。如果两个用户只有1-2个共同评分景点相关系数算出来往往虚高误导推荐结果。我加了一重保护——要求共同评分景点数至少达到3个才计入相似用户集合。这个阈值看起来简单但对推荐精度的正向影响远超预期。热度推荐就简单很多了直接SQL按浏览量倒序再按收藏量倒序取TopNSELECT * FROM scenic_spot WHERE city #{city} ORDER BY view_count DESC, favorite_count DESC LIMIT #{limit}Redis在这里派上了大用场。热度榜每个城市的数据30分钟才刷一次如果每次请求都打MySQL热点数据反复查询压力还是不小的。我用Redis存热点数据Key设计为hot_spot:{city}Value是JSON数组设置了30分钟过期时间。请求进来先查Redis不存在再查MySQL然后回填Redis。这套设计在高并发读场景下极为常见也是很经典的一种缓存穿透防御手段。3.4 路线推荐模块的实现思路路线推荐模块可能是这个系统里最产品化的功能。它不只是景点列表而是把景点组织成有主题、有节奏的游玩路线。这个模块解决的痛点很明确——用户收藏了5个景点但不知道怎么规划先后顺序和游玩时长路线推荐直接给了答案。路线数据是运营人员后台配置的每条路线包含以下属性路线名称比如海淀文化一日游京郊星空露营两日游路线主题绑定到标签体系里亲子主题、自然探索、美食之旅等关联景点列表每个景点在路线里有个排序序号和一个推荐停留时长适合季节因为周边游受季节性影响极大冬天的漂流路线肯定不推适合人群通过年龄段和人群标签来匹配用户画像路线的推荐逻辑很简单就是两重匹配第一重按用户最近搜索或收藏的景点所属标签匹配路线主题比如用户收藏了多个自然风光标签的景点就把京郊自然探索关联的路线优先推荐第二重是统计热度兜底直接取整体收藏量和浏览量最高的路线。关于路线规划有个细节值得展开。路线里每个景点除了序号还配置了建议游玩时间字段比如奥林匹克森林公园建议3小时。系统生成路线详情时会自动做时间轴汇总展示完整时间线比如上午9点-12点森林公园下午2点-5点科技馆。这个设计实现难度不大但用户体验提升显著用户一眼就能看出这条路线一天的节奏是否合理。建议如果还有余力可以做驾车/公共交通两种通勤时间的换算基于两点间的经纬度计算行驶时间和距离这块模型里的经纬度字段就是为此预留的。4. 前端页面和交互设计4.1 页面架构和组件划分前端用Vue 3 Vite搭建按页面维度拆分了六个主要视图首页、景点列表页、景点详情页、路线推荐页、个人中心页、登录注册页。路由用Vue Router状态管理用的Pinia请求库使用Axios封装拦截器。首页是最核心的页面整体布局分三块。顶部是城市选择器支持定位当前城市和手动切换城市。中部是轮播图推荐位——系统每天自动把推荐分最高的3个景点推到轮播位。下面是胶囊标签区展示当前城市的所有景点标签分类点标签直接筛选走景点列表页。再往下是两个并行推荐流猜你喜欢板块混合推荐结果和热门景点板块热度推荐结果。实测下来这两个板块用的推荐算法不同展示风格也做了差异化——猜你喜欢卡片设计偏大展示大图和核心标签热门景点偏简洁列表风格一行一个带排名序号。游览路线区块放在热门景点的下方用横向滑动的卡片形式卡片上展示路线名称、主题标签、适合天数、包含的景点数点击进去看路线详情和完整时间轴。景点列表页支持搜索、标签筛选、距离排序和评分排序。距离排序需要浏览器的Geolocation API获取用户当前位置然后通过后端接口传入经纬度SQL里用Haversine公式直接算距离排序SELECT *, (6371 * acos(cos(radians(#{lat})) * cos(radians(latitude)) * cos(radians(longitude) - radians(#{lng})) sin(radians(#{lat})) * sin(radians(latitude)))) AS distance FROM scenic_spot WHERE city #{city} HAVING distance 50 ORDER BY distance ASC这个距离筛选做了50公里默认半径的限制符合周边游的产品定位。景点详情页是这个系统里交互最复杂的页面。上方是图片轮播和景点基础信息下方分成三个Tab详情介绍富文本内容开放时间电话地址、相关推荐基于内容的相似推荐结果、用户评价评分分布评论列表带图评价。这里用户行为采集的埋点就集成在详情页的挂载事件里。个人中心页主要展示三块内容我的收藏瀑布流卡片、最近浏览时间线列表、我的路线收藏过和自定义创建的路线。4.2 前端性能优化和体验细节几个前端的性能调优手段值得分享。首屏加载优化上景点图和轮播图全部启用懒加载使用Vue的v-lazy指令实现。图片资源统一走CDN地址而且图片在服务端做了两套尺寸列表页用的缩略图300×200和详情页用的大图800×600避免前端强行拉伸影响体验和速度。推荐流的加载状态处理上骨架屏是必须的。我之前试过简单地用一个loading转圈但用户体验反馈很差——用户等待时完全不知道页面要展示什么会直接划走。改成骨架屏后即使接口响应需要1秒用户的体感也流畅很多。距离排序时的权限处理也踩过坑。用户在没授权地理位置时距离排序就不能做原生排序我做了降级方案——改为按照热门优先排序同时在排序栏显示一个提示开启定位可查看距离信息。这样的降级策略比直接报错或者隐藏功能要友好得多。4.3 前后端联调和接口规范联调过程中接口规范是保证效率的关键。我前后端的约定统一走RESTful风格返回结构统一为{ code: 200, message: success, data: { } }统一封装了ResultT泛型类作为控制器返回类型配合RestControllerAdvice做全局异常处理。前后端分离项目最怕接口各写各的约定好返回结构后前端的请求封装和状态判断能省出一大半重复工作。接口路径按资源维度命名。景点相关的GET /api/spots列表、GET /api/spots/{id}详情、GET /api/spots/similar/{spotId}相似推荐。推荐相关的三个接口GET /api/recommend/hybrid混合推荐、GET /api/recommend/hot热度推荐、GET /api/recommend/collaborative协同过滤。路线相关的GET /api/routes路线列表、GET /api/routes/{id}路线详情。行为相关的POST /api/behavior/view、POST /api/behavior/favorite、POST /api/behavior/rating。接口授权采用JWT机制。后端登录接口签发Token前端Axios拦截器在请求头统一加Authorization: Bearer token。后端用OncePerRequestFilter做Token校验拦截器免登录的白名单接口有登录接口、注册接口、景点列表和详情页——让游客也能浏览但收藏、评分、推荐接口必须登录。这个设计符合产品逻辑——游客可以先看内容产生兴趣后再注册成为用户而不是上来就强迫注册。5. 数据库设计和优化细节5.1 完整表结构脚本早期建表的时候其实走过弯路。我用MyBatis-Plus的自动建表功能快速生成了表结构但后来发现自动生成的字段注释缺失、索引缺失、字符集不统一后期排查问题非常痛苦。后来把建表SQL全部手工重写这里贴出核心表结构。景点表结构如下CREATE TABLE scenic_spot ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, name varchar(100) NOT NULL COMMENT 景点名称, city varchar(50) NOT NULL COMMENT 所在城市, district varchar(50) DEFAULT NULL COMMENT 所在区域, address varchar(255) DEFAULT NULL COMMENT 详细地址, introduction text COMMENT 景点介绍, tags varchar(255) DEFAULT NULL COMMENT 标签逗号分隔, cover_image varchar(500) DEFAULT NULL COMMENT 封面图URL, longitude decimal(10,6) DEFAULT NULL COMMENT 经度, latitude decimal(10,6) DEFAULT NULL COMMENT 纬度, phone varchar(20) DEFAULT NULL COMMENT 联系电话, open_time varchar(100) DEFAULT NULL COMMENT 开放时间, view_count int(11) NOT NULL DEFAULT 0 COMMENT 浏览数, favorite_count int(11) NOT NULL DEFAULT 0 COMMENT 收藏数, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态0下架1上架, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_city (city), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点信息表;用户行为表结构CREATE TABLE view_history ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, user_id bigint(20) NOT NULL COMMENT 用户ID, spot_id bigint(20) NOT NULL COMMENT 景点ID, scene_type varchar(20) DEFAULT NULL COMMENT 来源场景recommend/search/route/detail, view_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 浏览时间, PRIMARY KEY (id), KEY idx_user_time (user_id, view_time), KEY idx_spot_time (spot_id, view_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT浏览历史表;5.2 索引优化和查询性能实践热点查询场景我都加了合适的索引。比如首页按城市查热度榜走的是city view_count联合索引推荐时按标签查候选景点给tags加了普通索引但实际使用的更多的还是全表扫描后Java侧做相似度计算因为标签字段是逗号分隔的非原子值MySQL对这类字段的索引优化效果有限。这里有个实战观点想分享不要迷信索引。在某些场景下比如根据逗号分隔标签做模糊匹配查询走了索引反而可能因为回表次数过多导致性能下降。我当时对比过LIKE %tag%这种写法加不加索引都差不多因为前导通配符会导致全索引扫描还不如直接全表扫描后在应用层做过滤。后期数据量到万级以后再考虑把标签拆出来做子表或者用全文索引方案。分页查询上MyBatis-Plus自带的LambdaQueryWrapper配合分页插件用起来很顺手。要注意的是分页查询的前置条件是已经引入了MyBatis-Plus的分页拦截器否则Page对象不会生效这个坑很多新手会踩Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }5.3 数据初始化和测试数据准备系统运行起来需要基础的数据支撑我写了一个数据初始化组件在应用启动时检测景点表是否为空为空则从内置的JSON文件批量导入初始数据。初始数据覆盖了北京、上海、广州、成都四个城市的200多个必去景点每个景点都配置了完整的标签、经纬度、开放时间、联系方式等信息。测试数据的用户行为模拟也做了脚本化处理。写了一个DataMockController里面提供/api/mock/generate接口一键生成100个模拟用户以及每个用户30-50条随机浏览记录、10-20条收藏记录和5-10条评分记录。这些模拟行为数据不是完全随机的——景点标签的相对热度按齐夫分布来模拟这样造出来的数据更接近真实的用户行为分布。生成测试数据这个过程实际上也是验证推荐算法用的。有了数据之后我可以通过对比推荐列表中景点被再次消费的比率简单评估推荐质量。虽然不如大厂那种离线评测、在线AB实验那么严格但对小型项目已经足够有参考价值。6. 推荐效果验证和迭代优化6.1 策略人工评测和指标评估推荐系统做完了怎么验证效果好不好不能光靠感觉说话。我没有上复杂的实验平台用了一个相对简洁但有效的验证方案。先把100个测试用户的行为数据随机拆成训练集和验证集。训练集占80%用于生成推荐结果验证集占20%用于检验推荐的命中率。具体评估指标选了三个准确率Precision10、召回率Recall10和覆盖率Coverage。我的评估逻辑是对每个用户用训练集数据跑推荐算法生成10个推荐景点然后去看这10个景点里有多少出现在该用户验证集的真实行为列表中以此计算Precision10和Recall10。覆盖率则看整个推荐系统最终覆盖了多少景点热点推荐在这项指标上很差因为它永远只推那么几个头部景点。实测下来纯内容推荐的Precision10大约在21%纯协同过滤的Precision10大约在18%混合推荐能到26%左右。覆盖率最好的反而是内容推荐能到40%以上协同过滤在数据稀疏时会明显下降降到20%以下。混合策略在三个指标上基本达到了单一策略的平衡最优解。6.2 冷启动和稀疏性问题的应对经验冷启动问题是任何推荐系统都绕不开的坎。我给不同角色设计了不同的冷启动方案新用户冷启动因为没有行为数据推荐结果直接走热度推荐兜底。同时前端在产品层面做了配合——新用户首次进入推荐页时会弹出一个兴趣标签选择浮层让用户主动选择自己感兴趣的景点类型选了标签后再走基于标签的内容推荐这比纯热度推荐效果好很多算是显式反馈引导的一种落地方式。新景点冷启动更难。景点的标签是运营录入的新增景点如果热度分从0开始基本不会出现在推荐位。我做了加权方案新景点在创建后的前7天热度分额外乘以1.8的加权系数让它有机会进入热度榜尾。同时后台运营可以手动把新景点设置为精选推荐强制进入首页轮播推荐位。这比纯靠算法自动发现要靠谱得多。6.3 效果调优复盘和版本迭代记录项目迭代了两版算法逻辑。第一版只有内容推荐和热度推荐内容推荐的种子直接取最近浏览记录我前面讲过的CTR只有11%。第二版换成了收藏/评分种子加了协同过滤并做三层融合推荐点击率提升到23%左右。后续又把取消收藏作为负反馈纳入协同过滤的评分矩阵指标又提了3个百分点左右。迭代中的最大心得就是推荐系统的优化方向不是加更复杂的模型而是把用户行为信号用得更准确。浏览数据的信号质量没有收藏高收藏没有评分高评分没有取消收藏的负向信号信息量大。数据有噪音时算法再精巧都救不回来。先做数据清洗、筛选高信噪比特征再考虑模型层面的优化。7. 环境搭建、部署运行和常见问题7.1 从零到一运行这个项目项目要跑起来环境依赖其实不少这里把完整的步骤记录一下。第一步安装基础环境。JDK 8、Maven 3.6、MySQL 8.0、Redis 5.0、Node.js 14版本上不用完全对齐最新版稳定即可。前端用npm install装依赖后端用Maven导入pom.xml。第二步初始化数据库。先建一个city_travel数据库字符集选utf8mb4然后执行项目中sql目录下的建表脚本脚本会创建六张核心表。SpringBoot侧配置application.yml里的数据库连接、Redis连接和JWT密钥这三大项即可。第三步启动后端。执行mvn spring-boot:run或直接启动Application类端口默认8080。第一次启动会自动检测到景点表为空主动触发数据初始化控制台能看到导入的景点数量。第四步启动前端。前端项目根目录执行npm install和npm run devVite默认开5173端口。浏览器访问http://localhost:5173前端Vite配置里已经做了代理转发/api路径的请求会自动转发到本机8080端口。提示如果前后端跨域配置不对建议优先检查前端vite.config.js里的proxy配置而不是去后端写CrossOrigin。代理方式能有效避免Cookie跨域问题也更贴近生产部署的真实形态。7.2 高频报错排查速查表把项目运行期间遇到的典型问题整理成一张速查表方便大家直接对照排查。报错现象可能原因排查方法后端启动直接退出MySQL未启动/密码不对/数据库未创建检查3306端口连通性确认application.yml连接配置前端npm run dev报缺失依赖Node版本不兼容Vite 4升级Node到16或以上接口全部401Token校验失败/JWT密钥不对检查前端是否统一加了Authorization头图片加载404图片路径配了本地绝对路径统一改为相对路径或CDN完整地址推荐列表一直为空用户行为表没有数据调用/api/mock/generate生成模拟数据控制台出现Whitelabel Error Page接口路径不对或Controller未生效检查Controller类是否有RestController注解分页不生效未注册MyBatis-Plus分页插件按前文代码补MybatisPlusInterceptor配置7.3 调试推荐算法时的小套路最后说说调调推荐算法效果时的经验。强烈建议在后端单独开一个调试接口GET /api/debug/recommend/{userId}把推荐的完整链路展示出来种子景点是什么、候选集多大、每个推荐景点的各项得分和权重是多少、最终融合得分是多少。这类调试接口看着不起眼但排查问题时价值极高。比如某次推荐结果异常返回了一堆完全不相关的景点通过调试接口一眼就看出种子景点选错了——是因为用户点过几条无关景点广告导致最近浏览记录被污染。如果没有这个接口得翻日志查数据库才能定位到问题效率完全不是一个量级。第二个平时很少人提的点是给推荐结果增加recommend_reason字段在推荐卡片上展示因为你看过朝阳公园所以推荐了温榆河公园。这既提升用户对推荐结果的信任度也方便自己排查推荐逻辑的合理性。用户反馈这个推荐莫名其妙时看一眼推荐理由就知道是哪条链路出了问题。8. 项目扩展方向和商业化思考8.1 可以迭代的几个功能方向按这个数据模型和推荐逻辑可以往下扩展的功能方向很多按优先级排序我认为是这样。接入真实地图能力最值得优先做。当前系统的经纬度字段和距离计算都是纯服务端逻辑下一步可以接入高德或百度地图JS API把路线规划做得真正可用比如基于真实路况推荐自驾路线同时展示景点周边的餐饮、停车场信息。没有地图支撑的路线推荐始终缺了关键一环。引入UGC内容运营是做产品沉淀的关键。现在的景点数据和路线数据都是后台预先配置内容形态单一。下一步可以让用户提交自己的游记和照片甚至允许用户自己创建分享游玩路线。UGC内容一旦成规模推荐算法的素材池会丰富很多用户粘性和停留时长也会明显上涨。多模态信息展示是内容形态层面可以自然延续的方向。当前只支持图片轮播下一步加视频预览在景点详情页嵌入短视频推荐算法可以把视频完播率作为一个新的反馈信号接入评分矩阵。春节期间的景点视频完播率比静态图的浏览时长能高出好几倍这类强行为信号对推荐效果有很大帮助。8.2 部署上线的基本要点项目要真正部署上线需要注意的点跟本地跑还是有不少差别。打包需要做两件事后端用mvn clean package -DskipTests打成Jar包前端用npm run build产出静态资源文件。部署方案上推荐用Nginx同时托管前端静态资源和做后端反向代理不用单独开Tomcat。Nginx配置的关键部分如下server { listen 80; server_name yourdomain.com; # 前端静态资源 location / { root /opt/travel-frontend/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }数据库和生产环境的Redis都要做访问控制不要把端口直接暴露到公网用云厂商的安全组规则限制只允许应用服务器访问。MySQL和Redis的密码不要用默认值用复杂密码账号权限也最小化——应用账号只给它所在库的增删改查权限不给DDL权限。这一点做不好后面数据出问题找谁都救不回来。8.3 最后再分享一点经验总结这个项目前后折腾了两个星期最大的感想是一个靠谱的推荐系统三分靠算法七分靠数据。做推荐不是把协同过滤的公式抄过来就能跑出效果的更多精力要花在行为埋点怎么做、数据怎么去噪、种子怎么选、权重怎么调这些事情上。算法做到最后调权重参数、筛选高质量信号这些脏活累活才是决定系统好坏的核心。另一个体会是小型项目不要追求技术上的大而全。你在SpringBoot单体工程里同样可以做出清晰的分层架构可以跑起混合推荐策略可以通过调试链路去验证和优化效果。等业务量和数据量真的大起来了你再把推荐引擎独立成微服务也不迟——前期把数据模型和接口设计做规矩后面的演进会顺很多。如果你正在做类似的项目我的建议是不要只盯着代码看先把产品和数据想清楚。你的用户会怎么用这个系统他们什么时候会产生行为在什么流程里会留下最有价值的信号把这些想透了再动手写代码效率会高很多。这套源码里包含的是完整可运行的项目但真正能带走的是这些设计思路和踩坑经验。后续有疑问欢迎在评论区留言我把调试推荐算法的一些详细命令和脚本再整理出来分享给大家。
返回列表