SpringBoot+Vue3电影推荐系统实战:协同过滤与个性化推荐实现 1. 项目概述这套电影推荐系统到底值不值得做先说结论如果你正在纠结毕业设计选题、或者想搞一个能写进简历的完整前后端分离项目这套 SpringBoot Vue3 MyBatis 的个性化电影推荐系统是一个性价比很高的选择。为什么这么说因为电影推荐这个赛道有一个天然优势数据好拿、逻辑好讲、效果可见。用户评分、电影标签、观看行为这些数据都是现成的公开数据集推荐算法的核心思路无非是你之前喜欢什么类型我就拿这个类型继续喂给你做出来的效果用户能直观感知到不像某些管理系统做完连自己都不想点开。这套系统我前后折腾了大概三周整体架构是标准的前后端分离后端用 SpringBoot 2.x 提供 REST API持久层走 MyBatis 操作 MySQL前端是 Vue3 Element Plus Pinia算法部分手写了基于物品的协同过滤和基于内容的召回策略没有引入额外的推荐框架。这个项目做完我对从数据库表设计到推荐算法落地的整个链路理解比以前深刻很多。这套东西适合谁来参考一是准备做毕设的在校生二是想练手完整项目的前端/后端开发三是对协同过滤算法有兴趣但不想读论文、想直接看能跑代码的人。不需要你有推荐系统的理论基础我会把算法原理、数据库设计、坑点全部讲透。2. 技术选型思路为什么是这套组合而不是其他方案2.1 前后端分离的必要性很多人做毕设喜欢用 JSP Servlet 一把梭页面和服务端混在一起写。那种方式开发速度快但你想想你要在简历上写熟练使用前后端分离开发模式结果项目是 JSP 渲染的面试官一眼就能看穿。前后端分离真正的价值在于前端只管展示和交互后端纯输出 JSON 数据两者通过接口约定协作这种模式贴近企业真实开发环境。Vue3 相比 Vue2 最大的变化是 Composition API 和响应式系统的重构。我选用 Vue3 而不是 Vue2一方面是因为新项目没必要用旧技术栈另一方面 Element Plus 组件库只支持 Vue3生态已经跟上来了。你用 Vue2 当然也能实现同样的界面但既然从头写不如一步到位。2.2 SpringBoot MyBatis 的取舍逻辑MyBatis 和 JPA 之争从没停过。我的观点是推荐系统这种涉及复杂 SQL 查询的项目MyBatis 比 JPA 更合适。原因很简单推荐逻辑需要大量定制化 SQL比如找出所有用户对某部电影的评分记录、统计每部电影的平均评分和评分人数这类查询用 JPA 的派生方法写出来很别扭而 MyBatis 的 XML 映射文件里写 SQL 就是顺手的事。举一个实际例子。我要实现一个猜你喜欢接口核心查询是找出当前用户所有评分高于 4 分的电影 ID这个 SQL 很容易写select idselectHighRatedMovieIds resultTypejava.lang.Long SELECT movie_id FROM score WHERE user_id #{userId} AND score 4 /select然后我拿这批电影 ID 去查对应的相似电影再做去重和过滤。这种多步骤查询逻辑MyBatis 的灵活度比 JPA 舒服太多了。2.3 MySQL 数据库的角色定位MySQL 在这套系统里的定位很明确存基础数据和用户行为数据不做计算。有些初学者想用 MySQL 做相似度计算这是错误的方向。推荐系统的计算结果应该放在内存或本地缓存里MySQL 只负责提供原始数据。数据库设计上我没有过度设计。核心就是用户表、电影表、评分表再加一个管理员表。有人可能会问电影和导演、演员怎么关联我直接用了冗余字段把导演名和主演名存成字符串因为这套系统不需要做导演维度的联表查询冗余字段能少写三张关联表换来的代价是扩展性降低但对这个项目规模来说是划算的。3. 数据库建模与核心表结构设计3.1 建表思路用最少的表支撑推荐算法先贴核心表结构这是整套系统的地基。我设计表的时候有几个原则能冗余就冗余、能少关联就少关联、所有时间字段用 datetime 不用 timestamp。用户表 userCREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像URL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;电影表 movieCREATE TABLE movie ( id bigint(20) NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 电影名称, genres varchar(200) NOT NULL COMMENT 类型多个用|分隔, director varchar(100) DEFAULT NULL COMMENT 导演, actors varchar(500) DEFAULT NULL COMMENT 主演多个用|分隔, poster varchar(500) DEFAULT NULL COMMENT 海报地址, description text COMMENT 简介, release_year int(11) DEFAULT NULL COMMENT 上映年份, bias double DEFAULT 0 COMMENT 热度修正值算法使用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;评分表 scoreCREATE TABLE score ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, movie_id bigint(20) NOT NULL, score double NOT NULL COMMENT 评分 1-50表示已看未评, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_movie_id (movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这三张表就是推荐系统的全部数据基础。score 表我建了 user_id 和 movie_id 两个索引后续所有推荐相关查询基本都是基于这两个维度的过滤索引必须给到位不然数据量到几万条时查询就开始明显变慢。3.2 那几个容易忽略的设计细节第一score 表中的 score 字段我用了 double 而不是 int。有人会觉得评分不就是整数吗但我做算法时需要给不同行为设置不同权重比如用户点了个想看打 0 分、收藏打 3 分、实际评分给 5 分double 类型才有余量。第二movie 表的 genres 字段用竖线分隔存储切分后参与基于内容的推荐计算。这其实是一种半结构化设计平时展示直接读取算法计算时字符串切分一下就能用。如果拆成类型表和电影类型关联表那就要写多表 join对这个项目来说没必要。第三bias 字段是我后来加的。这个字段的含义是电影的活跃度修正简单说是有多少人评过分、平均分高不高的综合值。解决的是冷门高分电影和新电影在推荐中被低估的问题。计算方式是这个公式最终得分 相似度 * 0.6 热度归一化值 * 0.4热度归一化值用 bias 字段参与计算。3.3 为什么要特意保留一张行为记录表除了三张核心表我还加了 view_log 表记录用户浏览行为。这个表的作用不是给推荐算法直接用而是做后续的数据分析比如统计某部电影的曝光量和点击率。实际做的时候会发现推荐系统的迭代优化离不开用户行为的日志数据没有这个数据你根本说不清楚推荐效果是好是坏。表结构很简单id、user_id、movie_id、action_typeview/click/collect、create_time。每次前端调用电影详情接口时后端记录一条 view 日志。这个动作对性能影响很小但数据积累下来非常有用。4. 个性化推荐算法实现从原理到代码4.1 推荐策略选型协同过滤怎么在项目里落地这套系统的推荐算法分为三条链路基于用户的协同过滤、基于物品的协同过滤、基于内容的冷启动推荐。先说基于用户的协同过滤UserCF。原理是找到和你品味相似的一群人比如你俩都喜欢王家卫的电影把这群人喜欢而你没看过的电影推荐给你。UserCF 的适用场景是社交属性强的平台比如豆瓣它的实时性较好用户有新行为时能立刻感知。但这个算法在项目里有一个痛点用户数量少的时候相似用户很难找到推荐效果很不稳定。基于物品的协同过滤ItemCF逻辑正好反过来如果用户看过电影 A 并且打了高分那就找出所有和电影 A 相似的其他电影推荐给他。物品相似度可以在离线阶段提前计算好存起来线上推荐时只要查表就行性能非常好。我的选择是主用 ItemCFUserCF 作为辅助。原因很现实系统在测试阶段用户量最多也就几十个UserCF 根本找不到相似用户而 ItemCF 在几百部电影的数据规模下相似度矩阵离线算完直接存 HashMap 里线上接口响应时间在 50ms 以内非常稳定。4.2 基于内容的召回解决冷启动问题系统刚上线时,或者说一个新用户注册进来时,没有任何评分数据,协同过滤完全没法工作。这种情况就需要基于内容的推荐兜底。做法是把电影转成特征向量。每部电影提取这三维特征类型genres、导演director、主演actors。比如《霸王别姬》的特征向量是类型剧情|爱情,导演陈凯歌,主演张国荣|张丰毅。新用户注册时默认给他推荐全站热度最高的 12 部电影等他有了一两条评分记录后就能根据他评过高分的电影特征推荐同类型、同导演或同主演的其他电影。具体实现用到了 TF 向量的思路但做得很轻量。我把特征拼成一个字符串然后包一层相似度计算简单说就是词重叠越多越相似。然后找交集用户算余弦相似度。4.3 核心算法代码实现与讲解ItemCF 的关键步骤有三个构建用户-电影评分矩阵、计算电影两两相似度、根据用户历史评分生成 TopN 推荐列表。先看相似度计算的实现。我先把评分数据从库里捞出来组装成以 user_id 为 key 的数据结构// 构建用户-电影评分映射 MapLong, MapLong, Double userMovieRatings new HashMap(); for (Score score : scoreList) { userMovieRatings.computeIfAbsent(score.getUserId(), k - new HashMap()).put(score.getMovieId(), score.getScore()); }然后计算电影 i 和电影 j 的相似度。方法是对同时给 i 和 j 评过分的那批用户计算他们的评分向量余弦夹角private double calculateSimilarity(MapLong, Double movieIRatings, MapLong, Double movieJRatings) { SetLong commonUsers new HashSet(movieIRatings.keySet()); commonUsers.retainAll(movieJRatings.keySet()); if (commonUsers.size() 2) { return 0.0; } double dotProduct 0.0; double normI 0.0; double normJ 0.0; // 先算共同用户的评分点积 for (Long userId : commonUsers) { double ratingI movieIRatings.get(userId); double ratingJ movieJRatings.get(userId); dotProduct ratingI * ratingJ; normI ratingI * ratingI; normJ ratingJ * ratingJ; } if (normI 0.0 || normJ 0.0) { return 0.0; } return dotProduct / (Math.sqrt(normI) * Math.sqrt(normJ)); }这段代码的逻辑很直白。核心要点是相似度计算只用共同评过分的用户单边评分的用户对相似度没有贡献。这里有个小优化为了提高计算速度可以预先筛选出两个电影的评分用户集的交集再遍历这个交集避免对全部用户做无效计算。算完全部电影对的相似度之后就可以生成本地相似度矩阵文件。我直接把这个矩阵序列化到本地磁盘启动时加载。做 TopN 推荐时只要根据用户的高分电影查表找到相似电影加权汇总就能得到推荐列表public ListLong recommend(Long userId, int topN) { MapLong, Double userRatings userMovieRatings.get(userId); if (userRatings null || userRatings.isEmpty()) { return hotMovieIds; // 冷启动返回热门 } MapLong, Double recommendScores new HashMap(); for (Map.EntryLong, Double entry : userRatings.entrySet()) { Long movieId entry.getKey(); Double rating entry.getValue(); MapLong, Double similarMovies similarityMatrix.get(movieId); if (similarMovies null) continue; for (Map.EntryLong, Double simEntry : similarMovies.entrySet()) { Long candidateMovieId simEntry.getKey(); Double similarity simEntry.getValue(); if (userRatings.containsKey(candidateMovieId)) continue; // 去重 recommendScores.merge(candidateMovieId, similarity * rating, Double::sum); } } return recommendScores.entrySet().stream() .sorted((e1, e2) - Double.compare(e2.getValue(), e1.getValue())) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); }这段代码有两个地方值得注意。第一similarMovies是 film ID 到相似度值的映射在离线阶段已经算好并存入内存 Map线上每秒几百个请求都无压力。第二推荐相似度为什么不直接等于物品相似度而是用similarity * rating加权这是 ItemCF 的核心加权思路一部你打了 5 分的电影的相似片比一部你打了 3 分的电影的相似片推荐优先级要高因为有评分这个权重在里面。4.4 给推荐结果加一点点人情味推荐理由做完推荐之后我发现一个体验问题页面只推荐一个电影列表用户不知道为什么推荐这个给我。于是我在推荐接口里增加了一个 reason 字段。实现方式是在生成推荐结果时记录每个候选电影背后那个相似度最高的种子电影。MapString, Long reasonMap new HashMap(); // 在排序逻辑里记录来源 if (similarity threshold) { reasonMap.put(candidateMovieId : sourceMovieId, similarity); }这个功能做起来很简单但效果特别好。用户看到因为你看过《霸王别姬》所以推荐《活着》这句话信任感完全不一样。这也是一个值得写进简历的差异点可解释的推荐系统防止推荐黑洞效应。5. 从后端接口到前端页面完整实现链路5.1 后端 REST API 设计项目后端遵循标准的分层结构Controller 层只做参数接收和响应封装Service 层写业务逻辑Mapper 层只做数据库访问。我开发时做了一张接口清单核心接口分成三大类用户相关接口POST /api/user/register - 注册POST /api/user/login - 登录返回 JWTGET /api/user/profile - 获取个人信息PUT /api/user/profile - 修改信息电影相关接口GET /api/movie/hot - 热门电影列表GET /api/movie/detail/{id} - 电影详情GET /api/movie/search - 按关键词搜索GET /api/movie/recommend - 个性化推荐接口核心评分相关接口POST /api/score - 提交评分GET /api/score/user/{userId} - 获取用户的评分记录DELETE /api/score/{id} - 删除评分每个接口的返回格式都统一封装为ResultT结构{ code: 200, message: success, data: {...} }这样前端处理起来非常省事。5.2 Vue3 前端的关键实现细节前端使用 Vite 构建路由采用 Vue Router 4状态管理用 Pinia。要注意的是Vue3 的组合式 API 写法以script setup语法糖为主。首页的推荐区域数据源是推荐接口页面加载时 axios 请求后端/api/movie/recommend拿到电影列表后渲染成卡片形式展示在轮播区。每张电影卡片展示海报、片名、评分和推荐理由点击卡片跳转详情页。评分功能是前端的重点交互。用户可以在详情页给电影打 1 到 5 星打分后调用评分接口保存。评分影响有两处一是电影的展示评分会被更新后端做平均分计算二是推荐列表会刷新——新的评分记录是下一次协同过滤计算的新数据点。拦截器处理这块我踩了个小坑。我是用 axios 在请求拦截器里统一从 localStorage 获取 token附加到请求头 Authorization 字段。这个思路没错但要注意如果 token 过期后端返回 401前端要统一跳转登录页不要在页面里分散处理。// axios 请求拦截器 axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截器统一处理 401 axios.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(error); } );5.3 前后端联调时遇到的跨域问题开发阶段最常见的坑就是跨域。前端在 5173 端口跑后端在 8080 端口跑直接从前端向后端发请求一定会碰跨域限制。我的解决方式是在 Vite 配置里加代理// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这个配置的意思是前端所有/api开头的请求都转发到后端 8080 端口。这样浏览器看到的请求还是发给自己同源的地址就不会触发跨域拦截。生产部署时前端打包成静态文件放 NginxNginx 再配一个反向代理把/api转发到后端服务原理一样。6. 常见问题与排查技巧填坑实录6.1 MySQL 连接相关的坑这套系统最常遇到的问题是数据库连接失败。我遇到过的情形包括MySQL 8.x 和 5.x 的驱动类名不同、时区配置问题、密码加密方式不兼容。驱动配置的解决方式是在 application.yml 里这样写spring: datasource: url: jdbc:mysql://localhost:3306/movie_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver注意两个细节serverTimezone 必须显式指定不然 MySQL 8 会报时区错误driver-class-name 要用 com.mysql.cj.jdbc.Driver 而不是 com.mysql.jdbc.Driver。如果你用的是 MySQL 5.x驱动名是后者。6.2 推荐结果全是热门电影个性化不明显这是推荐系统最常见的假死状态。现象是不同用户看到的推荐列表一模一样全是全站热门。排查方法很简单看后端日志里推荐算法的输入数据量。如果你发现用户评分表里只有寥寥几条记录推荐系统自然无法区分用户的个性化偏好。协同过滤的数学本质决定了你必须有足够的评分数据才能算出有区分度的相似度。针对这个问题我做了数据处理脚本模拟用户对已有电影的行为用爬虫从公开数据源抓了大概 80 部经典电影的元数据然后给测试账户预设了 30 条评分记录评分随机分布在 3-5 分这样推荐结果立刻呈现出个性化差异。这种方法在测试阶段是必要的否则你很难向别人演示系统的推荐效果。6.3 相似度矩阵占用内存过高电影数量到了 5000 部以上时全量相似度矩阵的规模是 5000 x 5000底层用 HashMap 存储的话内存消耗很夸张。我的解法是只保留相似度大于某个阈值比如 0.5的电影对这样矩阵的稀疏程度会大幅增加。因为在推荐场景相似度低于阈值的电影对没有实用意义这部分存储可以安全舍弃。如果数据量再大还可以把相似度矩阵放到 Redis 里用 film ID 作为 key相似列表作为 value查询走缓存内存压力也能缓解。6.4 部署到服务器时接口返回 404项目在本地跑一切正常部署到云服务器后前端能打开页面但所有接口都返回 404。排查下来发现是因为前端打包后的静态文件里接口请求地址还指向 build 时的那个后端地址没有改成部署环境的真实地址。这个问题的解法是前端打包前把环境变量调整到生产环境或者后端接口统一用 Nginx 做一层路由代理保证前端请求的相对路径和后端实际路径匹配。6.5 常见问题速查表问题现象产生原因解决方案连不上数据库时区未指定或驱动类名错误application.yml 增加 serverTimezone确认驱动类名推荐结果全是热门片评分数据太少导入测试数据或重新造数启动时端口占用8080 或 5173 被其他程序占用换端口或杀掉占用进程前端打包后接口 404环境变量或代理配置错误检查 axios baseURL 与 Nginx 代理路径用户登录状态失效JWT token 过期前端拦截器统一处理 401 跳登录页电影海报不显示图片地址网络不可达切换图床或使用本地静态资源关于数据安全多说一句生产部署时MySQL 的 root 密码一定要设强密码JWT 密钥也要放在配置中心或用环境变量注入不要写死在代码里。虽然是个人项目但作为开发者要有意识这些基础安全习惯比项目本身更重要。7. 项目复盘与后续优化方向这套系统做下来我最大的感受是推荐系统并没有想象中那么高不可攀。核心算法拆开看就是一个相似度矩阵加一套排序逻辑几十行 Java 代码就能跑起来。难的地方在于怎么把算法、数据、业务场景揉在一起让系统在真实使用中不崩、体验不差。有一个细节现在回头看觉得很有价值初始化阶段就得想到有评分数据和没有评分数据两种场景下的不同处理路径。我的做法是主页推荐接口做了一次降级判断如果当前用户没有评分记录就返回全站热榜一旦有评分就走个性化推荐链路。这一步逻辑不复杂但避免了大面积报错和体验滑坡。后续可以优化的方向有几个一是把评分权重和时间衰减加进去让一个月前和昨天的偏好产生不同影响二是融合 UserCF 和 ItemCF两个结果按加权方式混合输出降低单一算法的偏置三是引入实时计算用户在首页点击了哪部电影的详情、停留了多久实时更新这个用户的偏好标签。这几个方向每一个都能单独开一篇文章写。最后分享一个做项目时比较重要的心态系统好不好要跑起来才知道。不要停留在代码写完就完事建议你把自己的电脑当服务器实际部署一遍然后用手机浏览器访问前端页面真实点几部电影、评几分走通整个用户流程。我这次做的时候光是联调的坑就踩了七八个但这些坑恰恰是这个项目最有价值的部分。把这些内容和解决方法写进项目的 README 和答辩 PPT 里就能天然形成一份有说服力的项目总结。