
看到不少同学一提起推荐系统就发怵觉得又是深度学习又是大模型门槛高得不行。其实推荐算法里最经典、也最适合工程入门的恰恰是协同过滤这一支——说白了就是找相似你和某个用户打分的口味相近他看过的电影你没看过那就推给你或者你给《盗梦空间》打了高分系统再找出那些同样被高分用户喜欢的《星际穿越》类电影推给你。算法原理没有想象中那么玄乎真正考验人的是把这套算法嵌进一个完整的SpringBootVue的Web项目里让前端、后端、数据库、算法四个环节顺畅地跑起来。这套系统的定位很清晰前端用Vue做页面展示和交互后端用SpringBoot提供RESTful接口算法层负责从用户评分数据中计算相似度、生成个性化推荐结果。它解决的是两个实际问题一是帮用户在大量电影里快速找到自己喜欢的二是让平台积累的评分数据越用越有价值。无论你是正在找JavaWeb方向的毕设选题还是刚入门推荐系统想看看工程落地长什么样又或者纯粹好奇视频网站猜你喜欢背后的实现逻辑这个项目都能给你一个很完整的参考。1. 系统整体设计与方案选型1.1 功能模块划分与核心流程先别急着写代码第一步一定是理清楚系统要做什么。一个基于协同过滤的电影推荐系统核心功能可以拆成三块用户模块、电影模块、推荐模块。用户模块负责注册、登录、个人资料维护还有最重要的评分行为。没有评分数据协同过滤就是无源之水。所以我建议在一开始就把评分设计成核心操作电影详情页、列表页、播放页都要能快速打星评分越方便用户越愿意贡献数据。电影模块就是常规的CRUD但要注意把搜索、分类筛选、详情展示做好。前端要展示海报、简介、导演演员、年份类型这些字段后端接口就要一次性把数据给全避免前端多次请求。推荐模块是系统的灵魂。用户登录后进入首页能看到为你推荐栏目里面是根据协同过滤算法生成的个性化片单同时还有热门电影作为冷启动兜底。推荐的完整流程是用户评分 → 数据入库 → 算法定时或实时计算相似度 → 生成推荐列表 → 前端展示。这一步的链路设计要提前想清楚不然后面算法写好了却接不进页面。1.2 算法选型UserCF还是ItemCF协同过滤分为基于用户的UserCF和基于物品的ItemCF两者各有适用场景。我在这个项目中建议至少把UserCF作为主推方案实现因为电影推荐场景里用户数量相对可控而且基于用户的推荐能给用户带来发现惊喜的感觉适合做演示和教学。简单说下两者的区别。UserCF的核心是和你口味相似的人喜欢什么就推荐给你。它的优势是推荐结果有社交属性能挖掘出跨领域的潜在兴趣点。比如你和另一个用户都喜欢科幻片那人还喜欢烧脑悬疑片系统就可能把悬疑片推给你这个惊喜感是ItemCF做不到的。ItemCF的核心是你喜欢的物品的相似物品。推荐结果更稳定、更容易解释所以电商平台用得最多你买了手机系统推给你手机壳。但电影这种内容消费领域ItemCF容易出现推荐结果高度同质化的问题全是续集、翻拍、同一导演的类似作品久了用户会腻。实际项目中很多人会两种都实现再通过加权融合。但对一个以学习和演示为目标的项目先把UserCF跑通、跑明白比贪多嚼不烂更重要。如果后面学有余力再补一个ItemCF做对比展示效果会非常好。1.3 技术栈版本选型与避坑建议技术栈选型上我直接给出一套经过验证的组合照着这个搭配能省掉大量折腾环境的时间。后端建议用SpringBoot 2.7.x系列不要一上来就追新用3.x。很多同学在IDEA里创建项目时默认拉取最新版本结果遇到JDK版本不匹配、配置方式变了、老教程代码跑不起来的问题。SpringBoot 3.x要求JDK 17起而且javax包改名成了jakarta网上大量资料和代码都是基于2.x的照搬过来会踩一堆莫名其妙的坑。所以求稳就选2.7.x配JDK 8或11生态成熟、资料丰富怎么搜都有答案。前端建议Vue 3 Vite Vue Router Pinia再配上Element Plus做UI组件库。Vue 3是现在的主流版本Vite开发体验比Webpack强很多冷启动秒开。Element Plus的表格、表单、评分组件直接拿来用界面效果很专业适合快速渲染出高质量页面。数据库用MySQL 8.0如果本机没装就用Docker拉一个非常方便。ORM层我用MyBatis-Plus代码量比原生MyBatis少一截分页、条件查询都有现成封装非常适合这类管理后台加业务系统的项目。2. 数据库设计与协同过滤算法实现2.1 数据表设计与评分模型数据库设计是整个系统的地基地基打不好后面算法算出来的数据全是错的。核心表就三张用户表、电影表、评分表。如果你需要管理员后台再加一张管理员表但核心逻辑都围绕这三张展开。用户表保存账号信息和基础资料密码一定要加密别用明文。电影表保存电影元信息和播放地址这里要特别注意预留video_url字段因为热门的m3u8流媒体播放要用到它。评分表是推荐系统的命脉每一行代表某个用户对某部电影的打分。三张表的建表语句骨架我放在下面CREATE TABLE user ( id int NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码BCrypt加密, nickname varchar(50) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE movie ( id int NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 电影标题, poster varchar(500) DEFAULT NULL COMMENT 海报URL, director varchar(100) DEFAULT NULL, actors varchar(500) DEFAULT NULL, genre varchar(100) DEFAULT NULL COMMENT 类型如 动作,科幻, release_year int DEFAULT NULL, rating decimal(3,1) DEFAULT NULL COMMENT 平台平均分, description text COMMENT 简介, video_url varchar(500) DEFAULT NULL COMMENT 播放地址支持m3u8, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT电影表; CREATE TABLE rating ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL, movie_id int NOT NULL, score int NOT NULL COMMENT 评分 1-5, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_movie (user_id, movie_id), KEY idx_movie_id (movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评分表;这里有一个很重要的设计细节评分表的user_id和movie_id要建联合唯一索引。业务上一个用户对同一部电影只能有一个评分用户如果改分数就做更新操作这样能保证数据绝对干净不会出现同一个人对同一部电影打分五次的情况。我在项目里见过不建唯一索引的后期算法跑出来的推荐结果乱到没法看。2.2 相似度计算与UserCF算法实现协同过滤算法的核心就两步算相似度、做预测。相似度的计算方式主流有三种余弦相似度、皮尔逊相关系数、Jaccard相似度。推荐系统里余弦和皮尔逊用得最多。余弦相似度看的是两个向量的夹角不管向量长度适合评分数据量差异大的场景。皮尔逊相关系数可以理解成去中心化之后算余弦相似度它先减去每个用户的平均评分消除用户打分习惯的偏差。有的用户习惯打4分以上有的用户习惯3分上下皮尔逊能把这种系统性的偏好差异抹平。UserCF的完整流程是拿到目标用户的所有评分 → 和其他用户逐一算相似度 → 取出相似度最高的K个邻居 → 在这些邻居评分过但目标用户没评分过的电影里用加权公式预测目标用户可能打多少分 → 按预测分排序取前N个作为推荐结果。我在项目里实际用的皮尔逊相关系数的Java实现大致如下/** * 计算两个用户评分向量的皮尔逊相关系数 * key为电影ID, value为评分 */ public double pearsonSimilarity(MapInteger, Integer user1Ratings, MapInteger, Integer user2Ratings) { SetInteger commonKeys new HashSet(user1Ratings.keySet()); commonKeys.retainAll(user2Ratings.keySet()); if (commonKeys.size() 2) { return 0.0; } int n commonKeys.size(); double sum1 0, sum2 0; double sum1Sq 0, sum2Sq 0; double pSum 0; for (Integer movieId : commonKeys) { double r1 user1Ratings.get(movieId); double r2 user2Ratings.get(movieId); sum1 r1; sum2 r2; sum1Sq r1 * r1; sum2Sq r2 * r2; pSum r1 * r2; } double numerator pSum - (sum1 * sum2) / n; double denominator Math.sqrt((sum1Sq - sum1 * sum1 / n) * (sum2Sq - sum2 * sum2 / n)); if (denominator 0) { return 0.0; } return numerator / denominator; }注意这里有个关键细节两个用户如果没有共同评分过的电影皮尔逊公式里n取不到值直接返回0否则会出现除零错误或者算出一个毫无意义的数值。这个边界判断一定要写在最前面。算完相似度之后预测目标用户对某部电影的评分光把邻居的评分直接平均是不够的。更科学的做法是加权平均再考虑到每个用户自己的打分习惯用相对于自己平均分的偏差来加权。调整后的预测公式是目标用户对电影i的预测分 目标用户平均分 邻居对电影i的评分相对邻居平均分的偏差的加权和。public double predictRating(int userId, int movieId, MapInteger, MapInteger, Integer ratingMatrix, MapInteger, Double similarityMap) { double userAvg getUserAverageRating(userId, ratingMatrix); double weightedSum 0; double weightSum 0; for (Map.EntryInteger, Double entry : similarityMap.entrySet()) { int neighborId entry.getKey(); double similarity entry.getValue(); if (similarity 0) { continue; } MapInteger, Integer neighborRatings ratingMatrix.get(neighborId); if (neighborRatings.containsKey(movieId)) { double neighborAvg getUserAverageRating(neighborId, ratingMatrix); weightedSum similarity * (neighborRatings.get(movieId) - neighborAvg); weightSum Math.abs(similarity); } } if (weightSum 0) { return userAvg; } return userAvg weightedSum / weightSum; }这个公式比单纯加权平均要准得多因为每个用户的评分尺度不同有的人喜欢打高分有人喜欢打低分扣掉平均值之后才能真正反映这部电影比你平时看的偏好多少。如果你用MovieLens数据集做测试会发现这个公式的预测误差明显更小。2.3 冷启动问题与热门榜兜底协同过滤最大的软肋是冷启动。新用户没有评分记录系统算不出他的相似用户推荐列表全是空的体验直接崩。新电影没有用户评分也永远进不了推荐池。针对新用户的兜底方案很直接推荐热门榜。热度不能简单地按平均分排否则一部只有一个人打了5分的冷门电影会排在榜首这不合理。我用的是贝叶斯平均的思路把每部电影的平均分往全局平均分方向收缩一些降低评分人数少带来的波动。针对新电影的兜底逻辑是系统默认给一些基础曝光可以随机出现在新片速递栏目里也可以按上映年份和类型匹配一些模板数据。实操上不建议在算法层面过度纠结冷启动先保证热门榜足够科学就能把大部分体验问题解决掉。3. SpringBoot后端工程落地3.1 项目初始化与工程结构在IDEA里创建SpringBoot项目时直接用Spring Initializr向导。Group填com.exampleArtifact填movie-recommendationJDK选8或11依赖勾选Spring Web、MyBatis-Plus如果没有就后面手动加依赖、MySQL Driver、Lombok。Spring Boot版本在界面上手动改成2.7.x别偷懒用默认的最新版本。工程结构我习惯按功能模块分包而不是按技术层次分包。按技术层分controller、service、mapper这样的包在简单项目里好用但业务一复杂就会出现一个模块的代码散落在几十个包里的情况。按模块分包的方式更直观com.example.movie ├── common // 统一返回体、全局异常处理 ├── config // 配置类如CORS、拦截器 ├── controller // 接口层 ├── entity // 实体类 ├── service // 业务层 │ └── impl ├── mapper // 数据访问层 ├── algorithm // 协同过滤算法相关 └── dto // 前端交互的数据对象algorithm包单独拎出来把相似度计算、评分预测、推荐生成都放这里和业务层解耦。这样算法想换成ItemCF或者基于内容的推荐时只需要在这个包里动手脚不会动到Controller和Service。3.2 RESTful接口设计与核心实现接口设计遵循RESTful风格把资源名词和HTTP方法对应起来。这个系统的主要接口可以这样规划功能方法路径说明用户注册POST/api/user/register校验用户名唯一性用户登录POST/api/user/login返回token获取推荐列表GET/api/recommend登录后返回个性化推荐获取热门榜GET/api/movie/hot未登录也可以访问电影搜索GET/api/movie/search?keywordxx模糊搜索电影详情GET/api/movie/{id}详情和播放地址提交评分POST/api/rating有则更新无则新增以评分接口为例Controller层只是薄薄一层核心逻辑都在Service里RestController RequestMapping(/api) public class RatingController { Resource private RatingService ratingService; PostMapping(/rating) public ResultVoid submitRating(RequestBody RatingDTO ratingDTO) { ratingService.submitRating(ratingDTO); return Result.success(); } }Service里面要根据评分记录是否存在决定是insert还是update联合唯一索引在这里就是兜底防止并发提交时插入重复记录。推荐接口是核心中的核心我要给它加上就近取数策略。UserCF算法里有个耗时的点每次都要遍历所有用户计算相似度。用户量小还好用户量一大性能就撑不住。一个常用的优化是倒排表先根据共同评分的电影快速筛选出候选邻居只对候选邻居算相似度而不是全量算。另一个是在内存里缓存相似度矩阵评分数据不变化就不重新计算。3.3 登录认证与接口权限控制这个系统涉及用户数据和个性化推荐登录认证必须做。我建议用JWT的方案相比Session方案更适合前后端分离的架构不需要服务端存会话状态。流程是用户登录成功后后端生成一个JWT token返回给前端前端每次请求在Header里带上Authorization: Bearer token后端用一个拦截器校验token解析出用户ID放到请求上下文里。这样推荐接口直接从这个上下文拿到当前登录用户不用前端每次把用户ID传过来。拦截器配置时有一个坑要提醒放行的路径一定要写全。登录、注册接口不用拦截电影列表和详情可以考虑不用拦截但推荐接口、评分接口必须拦截。我见过同学把所有接口都拦了结果前端登录页面自己都调不通注册接口排了半天错才发现是拦截器把注册请求也挡了。4. Vue前端实现与体验打磨4.1 前端工程搭建与路由设计前端工程我用Vite创建命令是npm create vitelatest movie-web -- --template vue。创建完成后安装依赖和路由、状态管理、UI库、HTTP库npm install vue-router4 pinia element-plus axios npm install -D sass前端环境配置这块Node版本要注意一下Vite 5以上需要Node 18。很多同学在vue安装及环境配置这个环节卡住大多是因为电脑上Node版本太老建议直接去Node官网装18 LTS或者20 LTS。路由设计上页面可以分为首页推荐热门、电影列表、电影详情、登录注册、个人中心。Vue Router的history模式比hash模式好看但部署时需要Nginx配合做try_files回退否则刷新页面就是404。路由参数传递用path加斜杠的方式最直观// 路由定义 { path: /movie/:id, name: MovieDetail, component: () import(../views/MovieDetail.vue) } // 页面跳转 this.$router.push(/movie/ movieId); // 详情页取参 const movieId this.$route.params.id;4.2 电影列表与推荐展示页电影列表页和推荐页的核心都是展示电影卡片。我建议做一个独立的MovieCard组件接收一个movie对象渲染海报、标题、年份、评分。这样推荐页、搜索页、电影列表页都能复用代码干净很多。推荐页在mounted的时候调用推荐接口拿到推荐列表后渲染成卡片墙。这里要注意前后端接口返回的数据结构要提前约定好我用约定是{ code: 200, message: success, data: [...] }前端axios封装里统一拦截非200的code直接弹全局错误消息。前端传参与后端接收的字段名也要对齐比如后端用movieId前端就别传id这个看起来是小问题联调时却能浪费半天时间。Element Plus的评分组件很贴心支持显示值加可交互两种模式。列表页用只读模式展示平均分详情页用可交互模式让用户打分打完之后调评分接口再刷新推荐列表能立刻看到推荐结果变化演示效果拉满。4.3 视频播放与m3u8流处理vue播放m3u8这个问题我特别拿出来说因为电影推荐系统做到后面大概率会集成视频预览功能而m3u8这种基于HLS协议的流媒体格式在Web端播放不是开箱即用的。现代浏览器原生的video标签不支持m3u8格式必须用hls.js或者video.js配合videojs-contrib-hls插件来播放。我推荐video.js的方案上手简单文档丰富。在Vue组件里的基本用法是import videojs from video.js; import video.js/dist/video-js.css; import videojs-contrib-hls; // 在mounted或拿到视频地址后初始化 const player videojs(this.$refs.videoPlayer, { controls: true, preload: auto, fluid: true, sources: [{ src: this.movie.videoUrl, type: application/x-mpegURL }] });集成播放器时有一个很隐蔽的问题视频地址和你的前端页面不在同一个域名下就会触发跨域限制。解决方法是开发环境用Vite的proxy代理生产环境用Nginx反向代理让视频请求走后端域名就不会有跨域问题了。另外一个细节是m3u8视频的鉴权Header需要通过特定接口动态获取时video.js默认的请求方式可能带不上需要自定义一个播放器事件去设置Header或者在拿到带签名参数的视频URL之后再传给播放器。5. 部署联调与高频踩坑实录5.1 前后端联调与跨域配置前后端分离联调时跨域问题几乎百分之百会遇到。开发环境下最简单的方案是配置Vite的代理把前端的/api请求转发到后端的8080端口这样浏览器看到的请求是同源的不会有跨域问题// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })如果你非要在后端配CORS方法倒是简单但要注意一个问题allowedOrigins不要写成*因为一旦用了*浏览器不允许同时配置allowCredentials(true)而JWT认证又需要带Authorization头两者冲突。这是我在项目里反复踩的地方。5.2 打包与Nginx部署前端开发完要上线npm run build打包之后会生成dist目录里面都是静态文件。这里第一个坑是静态资源路径。Vite默认的资源路径是绝对路径/assets/xxx如果你的站点部署在域名的子路径下比如http://ip:8080/movie-web/那就要修改Vite的base配置否则CSS、JS全部加载不出来页面光秃秃一片。这就是很多同学遇到的vue打包后布局异常问题的根源。第二个坑是Vue Router的history模式。前端路由跳转时走的都是前端路由但用户手动刷新某个子页面比如/movie/12Nginx会给后端发一个/movie/12的请求后端没有这个接口就返回404。解决办法是在Nginx里把所有路径都try_files回退到index.htmlserver { listen 80; server_name your-domain.com; # 前端静态资源 root /usr/share/nginx/html/movie-web; index index.html; location / { 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; } # 视频流文件代理避免跨域 location /video/ { proxy_pass http://127.0.0.1:8080/video/; proxy_buffering off; } }如果你一台服务器要部署多个web项目就在Nginx里配置多个server块或者用location前缀区分不同项目比如/movie-web/指向前端项目/admin/指向管理后台。nginx部署多个web项目本身不复杂核心就是每个项目一个server块或一个location块资源路径别冲突就行。5.3 高频问题速查表在项目开发和部署过程中我整理了一份高频问题速查表遇到问题直接对照排查能省很多时间。问题现象根本原因解决方案前端页面空白控制台报404Vite base路径未配置在vite.config.js中设置base为实际子路径刷新页面404路由history模式未配try_filesNginx增加try_files $uri $uri/ /index.html评分提交后推荐没变化推荐列表有缓存或算法未重新计算评分接口成功后清缓存或算法改为实时读取跨域请求失败前后端端口不同使用Vite proxy代理或正确配置CORS视频m3u8播放不了跨域或header权限问题通过Nginx代理视频地址或动态获取签名地址新用户推荐为空冷启动问题推荐接口返回热门榜作为兜底所有用户推荐结果一样评分数据太少相似度矩阵稀疏用MovieLens数据集预填评分数据引入SpringBoot 3依赖报错版本太高javax改jakarta改用2.7.x版本结合JDK 8/11数据库存中文乱码连接串缺失编码参数JDBC URL加characterEncodingutf85.4 评分数据的重要性跑推荐系统时最头疼的还不是代码而是没有数据。自己注册几个账号手动打分数据量太小推荐结果基本不可用。我的建议是直接用公开的MovieLens数据集里面有百万级用户对不同电影的评分下载后写个导入脚本把数据灌进数据库。用这套公开数据调试算法你会发现协同过滤的效果马上就出来了算法给出的推荐列表和电影的经典分类高度吻合。你甚至能看到清晰的科幻迷用户群文艺片用户群。这个过程特别有成就感也方便你验证代码算出来的推荐结果是否正确。如果你不想全量导入百万条数据也可以只导入其中一部分比如挑100个用户、5000条评分这样既能保证推荐效果又能让数据库查询快一些。我实测过几百个用户的数据跑UserCF算法在普通笔记本上也就是几秒钟的事完全够用。6. 项目扩展方向与个人心得系统做完基础版本之后还有不少值得扩展的方向。第一是引入ItemCF做双算法对比在界面上让用户切换推荐策略直观感受两种算法的差异。第二是加一个简单的相似电影展示在电影详情页推荐看过这部电影的人还看了什么这个稍微改一下算法包的接口就能实现。第三是接入真实的爬虫数据源定时抓取最新电影信息入库让平台内容保持更新。第四是引入Redis缓存推荐结果用户重复访问首页时直接命中缓存响应时间能从秒级降到毫秒级。这个项目做下来我对算法工程化这四个字的理解比看十篇论文都深刻。算法本身只有几十行代码真正的复杂度藏在数据一致性、接口设计、性能优化、异常兜底这些看起来不起眼的环节里。我个人的体会是不要把协同过滤想成一个高深莫测的算法它的底层逻辑就是你生活中的直觉——你发现某个人和你聊电影聊得特别投缘就愿意无条件相信他推荐的片单。系统做的事情就是把这种直觉量化成数学公式再用工程手段让它在Web上稳定地跑起来。最后再分享一个小技巧调通整个链路之后一定要自己多用几遍。注册两个账号用一个账号给五部电影打高分再用另一个账号给其中几部打同样的分数然后回到第一个账号刷新推荐页。当你亲眼看到推荐列表里真的出现了预期中的电影时那种原来算法真的在工作的实感比看任何教程都有说服力。推荐系统这条路并不容易但这个项目确实是一个值得认真做完的起点。