
又是一个毕业设计季。每年这时候总有不少同学在“到底做个什么题目”上反复纠结最后绕来绕去还是回到那几条老路商城、博客、管理系统。不是不好而是太容易撞车。去年我接手了一个挺有意思的题目——“springboot基于vue协同过滤算法的音乐推荐播放器”一开始以为只是个普通的CRUD项目做到后面才发现这个题目把后端框架、前端框架、推荐算法、流媒体播放这几块硬骨头全揉在了一起做完了以后对整套开发流程的理解完全上了一个台阶。这个项目说起来并不复杂用SpringBoot提供后端接口Vue搭前端页面用户能在网页上听歌、搜索、收藏系统根据用户的历史操作用协同过滤算法给每个人推一份“你可能会喜欢”的歌单。但真正把它落地涉及的东西远比想象中多。如果你是正在选毕设题目的同学或者想练手前后端分离加算法应用的开发者这篇文章应该能帮你在动手之前把整条路线看清楚顺便避开我踩过的那几个坑。1. 项目背景与选题思路1.1 核心需求拆解先别急着写代码把题目拆开看。这个题目可以分成三条主线SpringBoot后端职责是提供RESTful API包括用户登录注册、歌曲管理、歌单管理、行为记录播放、收藏、评分、推荐结果查询。Vue前端负责渲染页面、控制播放器、展示推荐列表通过Ajax与后端通信。协同过滤算法这是推荐模块的核心基于用户历史行为计算相似度产出Top-N推荐歌曲。三条线看起来各管各的但实际是串在一起的。后端不只做增删改查还要把推荐算法集成进来前端也不只是展示还要记录用户的行为数据回传给后端。很多同学做这类题目容易把算法单独写一个Python脚本演示完就扔了那不行真正有分量的毕设推荐逻辑是要能跑在系统里、随用户行为实时变化的。1.2 为什么选协同过滤而不是别的方法推荐算法主流有基于内容、协同过滤、混合推荐三种。我为什么选协同过滤因为它最贴合“基于用户行为”这个描述也最容易在课设/毕设这个体量里讲清楚。基于内容推荐需要给每首歌打大量标签风格、语种、年代人工成本高而且推荐结果容易越来越窄。协同过滤核心思想是“物以类聚、人以群分”只需要用户行为数据就能推荐不需要理解音乐本身。混合推荐效果好但工程复杂度高毕设答辩时很难三言两语讲清楚。协同过滤又分两类基于物品的ItemCF和基于用户的UserCF。我最终选择的是基于物品的协同过滤为主。原因很现实用户数量少的时候UserCF算起来很痛苦而且用户行为稀疏相似用户根本找不准。而ItemCF可以先通过用户对歌曲的评分或播放次数算出歌曲之间的相似度再给用户推荐“他听过的歌曲的相似歌曲”稳定性和可解释性都更好。注意不要在答辩的时候只说“我用了协同过滤”要能讲清楚“为什么在这个场景下用ItemCF而不是UserCF”。这一问几乎是必问的。2. 系统架构设计与数据建模2.1 前后端分离架构总览整体架构是典型的前后端分离前端Vue运行在8080端口开发环境默认后端SpringBoot运行在8081生产环境前端打成静态文件后可以放在Nginx里反向代理到后端。我有意把后端分成了几个模块controller接收前端请求返回统一JSON。service业务逻辑包括用户行为逻辑和推荐逻辑。dao/mapperMyBatis-Plus操作数据库。recommend算法相关包括相似度计算、推荐结果封装。前端用Vue 3 Vue Router Axios组件化拆成登录页、首页、播放栏、歌单页、搜索结果页。播放器单独封装成一个全局组件因为不论用户停留在哪个页面底部播放条都要一直显示、连贯播放。2.2 数据库表设计与关系数据库是MySQL核心表我设计了六张表名用途关键字段user用户表id, username, passwordsong歌曲表id, title, singer, album, duration, url, coversong_list歌单表id, name, description, coverlist_song歌单-歌曲关联表id, list_id, song_iduser_behavior用户行为表id, user_id, song_id, behavior_type, score, create_timeuser_like收藏表id, user_id, song_id, create_time其中user_behavior表是推荐算法的关键输入。我设计了一个behavior_type字段取值包括play播放、collect收藏、search_click点击搜索结果等等。每个行为对应不同的分数因为播放一次和收藏一次对“喜欢”的权重显然不一样。我在代码里做了一层分数映射public static final MapString, Integer ACTION_SCORE new HashMap(); static { ACTION_SCORE.put(play, 1); ACTION_SCORE.put(collect, 5); ACTION_SCORE.put(search_click, 2); }有了这张表算法才能从数据里算出“歌曲A和歌曲B到底像不像”。2.3 用户行为数据采集与清洗这是很多人忽略的一步。推荐系统最怕脏数据如果用户行为记录乱写算法结果就没有意义。我在前端做了这些采集动作用户点击“播放”按钮时调用后端接口POST /api/behavior/record传{userId, songId, behaviorType: play}。用户点击“收藏”时传behaviorType: collect。在搜索结果页用户点击某首歌进入播放记录一次search_click。后端收到请求后不能直接入库要先做简单校验用户是否存在、歌曲是否存在、时间是否合理。为了防止一个用户同一个行为被反复点击产生大量垃圾数据我加了去重逻辑比如播放行为同一个用户对同一首歌在30秒内重复播放只记最后一次。实操心得行为数据表一定要加create_time索引后面跑推荐算法时按时间段筛选数据会快很多。3. 协同过滤推荐算法的落地3.1 算法整体流程我的推荐流程分成三个大阶段离线计算歌曲相似度矩阵定时任务每天凌晨计算一次用户行为得分生成“歌曲间相似度”矩阵存入数据库或者内存缓存。在线推荐当用户请求推荐时找到该用户最近有过正反馈行为的歌曲比如播放超过3次、收藏过的歌取这些歌的相似歌曲按加权得分排序。实时补充如果用户行为很少走冷启动逻辑直接推送热门歌曲和随机歌单。离线计算的好处是用户请求推荐时不需要实时跑算法响应速度快。因为毕设体量下的歌曲数量可能只有几千条全量计算矩阵也就几万条记录MySQL完全扛得住。3.2 相似度计算公式这里需要补一点数学基础别怕很简单。我用的余弦相似度[ similarity(a,b) \frac{\sum_{u \in U_{ab}} r_{u,a} \times r_{u,b}}{\sqrt{\sum_{u \in U_a} r_{u,a}^2} \times \sqrt{\sum_{u \in U_b} r_{u,b}^2}} ]其中r_{u,a}代表用户u对歌曲a的评分由行为算出。如果两个歌曲被同一批用户经常一起听分子就大相似度就高。我在Java里用Map实现了一个朴素版本核心代码如下像这样按行展开写不会太长public MapInteger, MapInteger, Double calculateSimilarityMatrix() { // 1. 查出所有用户行为评分 ListUserBehavior behaviors behaviorMapper.selectAllBehaviorsWithScore(); // 2. 构建 用户 - [歌曲 - 评分] 结构 MapInteger, MapInteger, Double userSongs new HashMap(); for (UserBehavior b : behaviors) { userSongs.computeIfAbsent(b.getUserId(), k - new HashMap()) .merge(b.getSongId(), (double) b.getScore(), Double::sum); } // 3. 构建 歌曲 - 用户评分向量 MapInteger, MapInteger, Double songUsers new HashMap(); for (Map.EntryInteger, MapInteger, Double entry : userSongs.entrySet()) { int userId entry.getKey(); for (Map.EntryInteger, Double se : entry.getValue().entrySet()) { songUsers.computeIfAbsent(se.getKey(), k - new HashMap()) .put(userId, se.getValue()); } } // 4. 计算两两相似度 MapInteger, MapInteger, Double simMatrix new HashMap(); ListInteger songIds new ArrayList(songUsers.keySet()); for (int i 0; i songIds.size(); i) { for (int j i 1; j songIds.size(); j) { int s1 songIds.get(i); int s2 songIds.get(j); double sim cosineSimilarity(songUsers.get(s1), songUsers.get(s2)); if (sim 0) { simMatrix.computeIfAbsent(s1, k - new HashMap()).put(s2, sim); simMatrix.computeIfAbsent(s2, k - new HashMap()).put(s1, sim); } } } return simMatrix; }cosineSimilarity方法就是套公式取两个map的交集计算点积和模长这部分很简单代码就不全贴了。3.3 推荐列表生成逻辑拿到相似度矩阵后给用户推荐就很简单。我写了一个RecommendService逻辑如下找到用户最近30天内有正反馈的歌曲列表记为likedSongs。对于likedSongs里的每首歌song从相似度矩阵取出与其最相似的N首歌。对这些候选歌曲按综合得分汇总排序[ score \sum_{s \in likedSongs} similarity(s, candidate) \times score(s) ]其中score(s)是用户对s的评分相似度越高、历史评分越高candidate排得越前。 4. 过滤掉用户已经听过的歌防止推荐重复。这一步市面上有很多现成的算法库但毕设自己写一遍更好因为答辩时老师几乎一定会问“推荐结果怎么来的”能现场画图讲清楚很加分。3.4 冷启动处理冷启动是推荐系统的经典问题新用户或者新歌没有行为数据协同过滤直接失灵。我的处理方式很简单新用户推荐热度榜热度按播放次数和收藏次数加权计算。新歌在歌曲详情页标注“新歌首发”同时在新歌上架后的一段时间内随机推给部分用户获取初始行为数据。虽然不是最优解但在课程设计中完全够用而且也让项目有了“考虑问题全面”的加分项。4. SpringBoot后端核心实现4.1 工程结构与接口设计后端我用的是SpringBoot 2.7 MyBatis-Plus MySQL。开发工具用的IDEAJDK版本是1.8。工程结构如下src/main/java/com/music/recommend |-- controller | |-- AuthController.java | |-- SongController.java | |-- BehaviorController.java | |-- RecommendController.java |-- service | |-- UserService.java | |-- SongService.java | |-- BehaviorService.java | |-- RecommendService.java |-- mapper | |-- SongMapper.java | |-- BehaviorMapper.java |-- recommend | |-- ItemCFRecommender.java | |-- HotRecommender.java |-- config | |-- CorsConfig.java | |-- ScheduleConfig.java接口基本采用RESTful风格统一返回结果用ResultT包装public class ResultT { private Integer code; private String message; private T data; }下面是我常用的几个核心接口方法路径说明POST/api/auth/login登录POST/api/auth/register注册GET/api/song/search?keywordxxx搜索歌曲GET/api/song/info/{id}歌曲详情POST/api/behavior/record记录行为GET/api/recommend/{userId}?limit20获取推荐列表GET/api/song/hot热门歌曲4.2 推荐模块的定时计算与缓存相似度矩阵如果每次请求都现算数据库会被打爆。我的做法是Spring Schedule定时做离线计算每天凌晨2点跑一次Component public class RecommendTask { Autowired private ItemCFRecommender recommender; Scheduled(cron 0 0 2 * * ?) public void calculate() { recommender.updateSimilarityMatrix(); } }计算完成后把矩阵存入Rediskey为recommend:sim:matrixvalue用JSON存。在线的推荐请求直接读Redis不再查表。Redis在这里的作用就是缓存没有它也行但有了它能展现你对性能的思考也在答辩时多一个可聊的话题。4.3 流媒体播放与静态资源映射歌曲文件放在服务器本地磁盘比如/data/music/后端做静态资源映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/music/**) .addResourceHandler(file: musicFilePath); } }前端播放器直接用audio标签src指向http://localhost:8081/music/song123.mp3这样后端不用额外做切片处理。但如果歌曲文件太大建议还是转成128kbps的MP3体积能小一半加载更快。踩坑提示SpringBoot上传文件默认大小限制只有1MB如果你做了歌曲上传功能必须在application.yml里把spring.servlet.multipart.max-file-size调大比如100MB。5. Vue前端页面与交互实现5.1 环境搭建与路由设计Vue前端我用的是Vue 3 Vite比用Vue CLI启动快不少。安装依赖就用npm没啥好说的倒是npm换源这个事值得提一嘴npm install -g cnpm --registryhttps://registry.npmmirror.com用cnpm安装依赖能省下大量时间。前端页面文件夹结构大致长这样src |-- api | |-- song.js | |-- recommend.js | |-- behavior.js |-- assets |-- components | |-- PlayerBar.vue | |-- SongItem.vue |-- views | |-- Login.vue | |-- Home.vue | |-- Search.vue | |-- Playlist.vue |-- router |-- store路由设计上我用了懒加载避免首屏加载太多无关页面const routes [ { path: /, redirect: /home }, { path: /home, component: () import(/views/Home.vue) }, { path: /search, component: () import(/views/Search.vue) }, { path: /login, component: () import(/views/Login.vue) } ];5.2 播放器组件与全局状态播放器是前端最麻烦的一块。我要解决两个问题一是切换路由时音乐不停二是播放状态全局共享。我用了VuexPinia也行存一个player模块保存当前播放歌曲、播放状态、播放进度、播放列表。PlayerBar组件放在App.vue里在任何路由下都渲染。核心逻辑可以用一张表说清楚操作触发方式Stream/状态变化点击列表中的歌曲SongItem组件中click设置currentSong调用播放器load并play点击播放/暂停PlayerBar控制调用audio.play()或pause()自动播放下一首audio的ended事件从播放列表中取下一首播放器代码关键就几行没必要用那些大型播放器库原生audio够用了audio refaudio :srccurrentSongUrl endedhandleEnded/audio注意不少浏览器会自动阻止带声音的自动播放用户第一次点击播放时不要慌那是浏览器策略导致的不是代码问题给用户一个显眼的播放按钮就对了。5.3 推荐结果展示与用户反馈推荐列表接口拿到的数据是[songId, title, singer, score]前端在首页单独划出一块“猜你喜欢”区域。我特意加了两个跟推荐效果有关的小功能推荐原因展示后端返回推荐列表时带上一个reason字段例如“因为您收听过《XXX》”这种可解释性让系统看起来更聪明。点击“不喜欢”用户在前端点不喜欢后后端把该songId拉入排除表后续推荐不再出现该歌曲。这两个小功能工程量不大但会让整个项目从“演示demo”变成“有人味的系统”答辩展示时非常加分。6. 常见问题与调试实录6.1 相似度矩阵计算慢第一次全量计算时我没有缓存直接每次请求都重算结果数据库连接直接被打满。后来加了定时任务和Redis才解决。如果你的歌曲数量很多比如上万条两两计算复杂度是O(n²)时间会暴涨。这种情况下建议只保留“近期有行为的歌曲”参与计算或者引入Spark这种分布式框架。毕设级别用不到但老师可能会问要有准备。6.2 跨域问题前端运行在http://localhost:5173Vite默认端口后端是http://localhost:8081浏览器发Ajax请求必然跨域。我写了一个CorsConfigConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true); } }注意allowedOrigins如果写了具体地址就别用*否则allowCredentials(true)会失效。6.3 播放器有时音频不加载排查思路很直接先用浏览器开发者工具看Network里audio请求有没有返回206如果后端接口返回403或者404检查静态资源路径映射。另外有些浏览器对MP3编码有要求用工具把FLAC转成标准MP3最省事。6.4 推荐结果全是热门歌曲如果你发现推荐引擎没起作用多半是行为数据太少了。我自己测试时只点了几首歌相似度矩阵稀疏得可怜推荐结果和热门榜没区别。解决办法是写一个测试数据生成脚本随机给50个用户分配几百条行为记录保证算法生效。这里分享一个小经验答辩前一定要提前准备好一份“看起来合理”的测试数据让推荐列表有差异、有层次不然现场演示会非常尴尬。结语做这个项目最大的感受是一个听起来没什么技术难度的题目真正做起来环环相扣。SpringBoot负责接口和算法调度Vue负责把推荐结果变成用户体验协同过滤则负责让用户感觉“这个系统懂我”。如果只是照着教程抄一遍代码很难体会到这种串联的快感。我建议你把算法部分自己多推演几遍把相似度公式在纸上画一画再动手写代码。最后再提醒一句毕设答辩时老师最看重的不是你用了多新的技术栈而是你有没有把问题想清楚、把逻辑讲圆。祝顺利。