
这是一篇花了我不少心思整理的项目复盘贴。去年我完整做了一个基于 ThinkPHP Vue 的个性化视频电影推荐系统数据层自己写爬虫采集推荐算法也是自己调通并且上线跑起来的。整个过程走下来从爬虫选型到推荐效果调优再到前后端联调踩过的坑一个个都记在心里。这篇帖子里我会把技术选型的逻辑、模块怎么拆分、算法怎么落地、前端怎么配合以及那些文档里不太会写的实操细节全部摊开来讲希望能给正在做类似毕设、课设或者想入门推荐系统开发的朋友参考。1. 项目整体设计与技术选型思路1.1 从需求出发这个系统到底要做什么先明确项目的核心诉求。它不是一个普通的电影信息管理网站而是一个“个性化推荐系统”每个用户登录后看到的内容应该不一样。用户看了哪部片子、打了什么分、收藏了什么类型系统要能从中学习然后在下次访问时给出合理的推荐列表。所以这个项目实际上包含了三个层面的工作。第一层是数据层需要有足够多的电影数据作为推荐素材这部分由爬虫解决第二层是算法层需要根据用户行为计算推荐结果这部分是系统的灵魂第三层是应用层需要把数据、算法结果通过接口提供给前端页面让用户真正能浏览、能评分、能看推荐。如果只做增删改查没有算法那就只是普通管理系统如果只有算法没有界面又无法直观展示效果。把这三层同时做完才算完整。既然要做毕设或者个人项目就不能把规模定太大。我的目标是单机环境下也能流畅运行数据量在万级电影、千级用户之内不影响体验。这个定位直接影响了后面的技术选型不引入重量级大数据框架不做实时流计算用最简单可靠的方式把推荐跑起来。1.2 四层架构数据、推荐、接口、界面如何分工整个系统我用了四层架构来组织这也是“大数据”在毕设项目中一个很务实的落地方式。所谓基于大数据不一定要上集群而是指完整走一遍“数据采集、数据清洗、数据分析、结果应用”的流程。数据采集层用 Python 编写爬虫脚本从公开网页采集电影基本信息、评分数据、评论摘要等写入 MySQL。这部分解决“数据从哪来”的问题。数据分析层离线运行推荐算法程序把用户行为数据转成推荐结果也产出统计报表数据例如电影评分分布、类型占比、热门榜单。这部分解决“数据怎么用”的问题。业务接口层ThinkPHP 提供 RESTful API负责用户登录、电影列表、推荐列表、收藏评分等业务逻辑。推荐结果是直接查表返回不依赖 PHP 实时计算。前端展示层Vue 负责页面交互通过 axios 调后端接口渲染电影卡片、推荐列表、数据可视化图表。这个分层最大的好处是每一层都能独立测试。爬虫出问题不影响接口算法调参不影响前端开发。我当时就是先把四条的线分别跑通最后才让它们协作联调时的压力小很多。1.3 技术选型背后的三点考虑选择 ThinkPHP Vue 这套组合主要有三个现实原因。第一是生态成熟、学习资料多。ThinkPHP 是国内 PHP 开发者非常熟悉的框架路由、ORM、验证器、中间件的封装很完整写一个接口项目非常快Vue 更不用说组件化开发让页面组织特别清晰。第二是前后端分离的开发模式好维护。前端只管渲染后端只管数据推荐算法的计算结果只要按约定格式输出前端切图换肤都不影响逻辑。第三是后期答辩展示效果好。老师或评委打开页面看到的是一套完整的 Web 应用既有交互动画也有可视化报表远比只给一段 Python 代码或者一个简单后台有说服力。我知道有些朋友会纠结要不要用 Java 或者 Go 写后端。如果单纯追求性能PHP 确实不是最优解但在这个场景下业务逻辑并不复杂核心计算又在离线 Python 脚本里PHP 完全能扛住。与其在语言选型上纠结太久不如早点把核心链路搭起来。项目能跑、效果能看、逻辑能讲清楚远比技术栈本身高级更重要。还有一点要提醒如果你在 Windows 上开发PHP 环境建议直接用集成环境搞定例如在本地安装一个带 ThinkPHP 项目的容器或者使用图形化集成工具别把时间浪费在手动配置 Apache 和 PHP 的环境变量上。Vue 构建建议用 npm依赖安装遇到网速问题可以切换国内的镜像源版本锁定用 package-lock.json 提交避免队友和你环境不一致时出现的诡异问题。2. 爬虫模块先把数据喂饱再谈推荐2.1 数据字段设计一张表搞定电影信息推荐系统能推荐什么完全取决于数据表里有什么字段。我建议把核心表设计成下面这样既简单又够用后续做基于内容的推荐也能直接复用。字段名类型说明movie_idint电影主键自增titlevarchar(255)电影名cover_urlvarchar(500)海报封面图directorvarchar(100)导演actorsvarchar(500)主演逗号分隔genrevarchar(200)类型例如“剧情,爱情”regionvarchar(50)制片地区release_timedate上映日期ratingdecimal(3,1)豆瓣/猫眼等评分rating_countint评分人数summarytext剧情简介created_atdatetime入库时间这里有个容易被忽略的细节类型字段我用逗号分隔存成字符串而不是单独建三张表做多对多关联。原因很直接推荐算法读数据时希望一次性把类型取出来做标签匹配拆成关联表会增加不必要的 join 操作对毕设级别的项目纯属过度设计。如果你以后要按类型做复杂的筛选统计再考虑拆表也不迟。行为表也提前设计好。用户对电影的操作无非三类浏览、收藏、评分。我分别用 user_history 记录浏览记录user_favorite 记录收藏user_rating 记录评分和评分的分数值。这三个表字段很少但三个表一起就是推荐算法的核心输入。评分这张表我加了 rating_time 时间戳后面算时效性权重时会用到。2.2 采集策略requests 还是 Scrapy 怎么选爬虫写起来不难难的是稳定。我一开始用的是 Scrapy 框架因为它的并发调度和中间件机制很成熟。但实际跑下来发现对于几百页级别的电影数据Scrapy 反而显得有点重调试麻烦环境依赖多而且在虚拟环境里安装时容易踩坑。后来我回归了 requests BeautifulSoup 的组合代码量短逻辑直白配合一个简单的队列循环就足够完成整站数据的采集。具体的思路是这样先找一个公开的电影信息站点确定它的列表页 URL 规范例如第 n 页的地址是某个固定格式然后请求列表页提取当前页里所有电影详情页的链接再逐个请求详情页解析出标题、导演、主演、类型、简介等字段最后存进 MySQL。整个流程可以抽象成一个纯函数式的循环每一层出错都单独容错不会因为一条数据解析失败而中断整轮采集。要注意的是采集时必须控制请求频率。我的做法是每请求一个页面 sleep 0.5 到 1 秒并且给请求头加上正常的浏览器 User-Agent例如 Mozilla/5.0。服务器如果返回 403 或者 302 重定向我会记录日志并放慢速度而不是立刻重试。很多初学者一上来就循环并发发请求结果 IP 很快被限制数据没收完还要等很久才能恢复。我更建议你把“采集”和“解析”拆成两步。第一步只把详情页的 HTML 原样保存到本地文件或者数据库里第二步再离线解析抽取字段。这样做的好处是如果网站的 HTML 结构调整了你只需要改解析规则不必重新抓一遍网络。实测下来这种方式能让调试效率和稳定性同时提升一倍以上。解析代码也用 XPath 或者 CSS 选择器别用正则去抠 HTML看得头晕不说还特别容易漏字段。BeautifulSoup 的 select 方法就很好用。2.3 数据清洗与入库别让脏数据毁掉推荐数据采集回来一定要经过清洗才能入库。我当时统计过大约有 15% 的记录存在各种问题最常见的有这几种。上映日期是“2023-10-01 00:00:00”这种带时间的字符串入库前要截成 YYYY-MM-DD。评分字段偶尔为空或者出现“暂无评分”的字样要统一处理为 NULL后面 SQL 统计时用 IS NOT NULL 过滤。演员字段混杂着多余空格、换行符要 strip 掉否则推荐系统把“刘德华”和“刘德华 ”当成两个人。有少量完全解析失败的页面会导致所有字段为空这种记录直接丢弃。清洗代码不长就是一组条件判断加正则替换。关键是不要把清洗逻辑写在爬虫脚本里而是单独放在一个 clean_data.py 模块里。爬虫抓到原始数据清洗模块统一加工再交接给入库函数这样职责分离之后哪一环出问题都能快速定位。入库时我建议使用批量 insert。一次插入 100 条记录SQL 的效率比循环单条插入高很多。ThinkPHP 那边的 Db::name(movie)-insertAll($data) 也支持批量插入不过 Python 入口已经写库了PHP 只负责读取这样 PHP 的逻辑更纯粹。2.4 定时抓取与增量更新电影数据不是一成不变的新片上映、评分变动、热门榜刷新这些都是持续发生的事。我的做法是写两个脚本一个全量采集脚本只在系统初始化时跑一次一个增量采集脚本每天凌晨两点定时执行只采集最近一周内更新的电影和评分变化。定时任务在 Windows 上用计划任务在 Linux 上写 crontab 就行。增加到系统计划里的命令大概是每天 02:00 执行一次 python /path/to/update_daily.py。增量脚本的核心是靠时间戳判断数据库里已存在的电影记录如果评分变了或者简介更新了就更新对应字段不存在的新片就插入新记录。另外采集脚本一定要写日志。当时我用 Python 的 logging 模块把每次采集的抓取数量、新增数量、失败数量全部记录到文件中。推荐系统后期调试哪条电影数据不对时看日志就能追查到是采集阶段的问题还是清洗阶段的问题。这一个习惯帮我省了非常多的时间。3. 推荐算法从热门排序到个性化推荐的进化3.1 推荐效果靠什么决定很多人觉得推荐系统高大上其实核心就一件事预测用户会喜欢什么然后把预测分数最高的内容展示出来。效果好不好取决于两大因素一是数据质量行为记录越真实、数量越多效果越好二是策略本身也就是用什么算法把历史和当前偏好映射成一个分数。我在一开始实现了一个“热门榜推荐”直接按评分和评分人数加权排序。这种做法不是个性化但作为兜底方案非常实用用户刚注册、没有历史数据时热门榜是唯一合理的推荐内容。真正的个性化推荐我用了协同过滤这个思路它的基本假设是跟你偏好相似的人喜欢的东西你大概率也会喜欢跟你喜欢的物品相似的物品你大概率也会喜欢。这两个方向分别对应 UserCF 和 ItemCF我两个都实现了线上跑下来发现 UserCF 在这个电影场景里的效果更符合预期。3.2 基于用户的协同过滤最经典的个性化方案UserCF 的完整流程分四步读数据、算相似度、找最近邻居、生成推荐列表。我直接用 Python 写离线脚本先从数据库里把最近 30 天的有效评分读取出来整理成{user_id: {movie_id: rating}}的嵌套字典然后计算用户间的相似度。Python 代码大致长这样import math from collections import defaultdict def load_ratings(): # 从 MySQL 读取评分数据返回 {user_id: {movie_id: rating}} pass def user_similarity(ratings): # 构建物品到用户的倒排表 item_users defaultdict(set) for user_id, movies in ratings.items(): for movie_id in movies: item_users[movie_id].add(user_id) # 统计用户间共现次数 cooccur defaultdict(lambda: defaultdict(int)) for movie_id, users in item_users.items(): for u in users: for v in users: if u ! v: cooccur[u][v] 1 # 用余弦相似度归一化 sim_matrix defaultdict(dict) for u, related in cooccur.items(): for v, cnt in related.items(): sim_matrix[u][v] cnt / math.sqrt(len(ratings[u]) * len(ratings[v])) return sim_matrix很多同学看到这段代码会疑惑不是有评分吗为什么相似度只看“共现次数”因为评分尺度和打分习惯因人而异有的人习惯打 9 分以上有的人喜欢打 7 分左右直接用分数相减会产生很大的偏差。余弦相似度只看“两个人是否共同看过同一批片子”再加上共现次数作为权重已经是简单有效的方案。如果想让精度更高可以换成皮尔逊相关系数把评分的平均值也考虑进去我在后面的调优阶段就切到了皮尔逊。生成推荐列表的思路是找到与目标用户相似度最高的 K 个用户把这 K 个人看过而目标用户没看过的电影按相似用户的相似度乘以他们的评分做加权求和得分越高排名越靠前。def recommend(user_id, sim_matrix, ratings, k10, top_n20): user_scores defaultdict(float) user_rated set(ratings[user_id].keys()) similar_users sorted(sim_matrix[user_id].items(), keylambda x: x[1], reverseTrue)[:k] for other_user, sim in similar_users: for movie_id, rating in ratings[other_user].items(): if movie_id in user_rated: continue user_scores[movie_id] sim * rating recommended sorted(user_scores.items(), keylambda x: x[1], reverseTrue)[:top_n] return [movie_id for movie_id, score in recommended]这段逻辑跑完之后算法输出的是每个用户的推荐电影 ID 列表。我要强调一个关键设计不要在用户请求推荐时才实时算。推荐结果每隔一段时间离线计算一次算完写进数据库的 user_recommend 表线上接口只需要按照 user_id 查这张表返回数据。这样 PHP 那边几乎零压力前端体验也流畅。实时计算只适合做热榜这种全量统一的逻辑个性化的结果全部走离线。3.3 基于物品的协同过滤换一个角度考虑问题ItemCF 的原理和 UserCF 正好对称不再找相似的人而是找相似的电影。即“看过 A 的你也可能会喜欢和 A 类似的 B”。先用所有用户的评分记录构建电影之间的相似度矩阵再看目标用户的评分历史匹配历史上高评分电影对应的相似电影。在电影这个场景里ItemCF 有个很大的优势相似矩阵的规模只跟电影数量有关不随用户人数增长而膨胀。如果系统用户规模未来上千UserCF 的相似度矩阵可能是平方级增长而 ItemCF 会稳定很多。我当时因为数据量小两个都做了对比实验UserCF 更个性化、推荐列表更多元ItemCF 更稳定适合做“相似推荐”模块。所以在页面里我两个都用了主推荐位放 UserCF 结果电影详情页里的“猜你喜欢”栏目放 ItemCF 结果。ItemCF 相似度计算也不复杂核心就是把“用户对物品”的矩阵转置成“物品对物品”的共现矩阵然后归一化。实现时要注意一个细节如果只看“两件物品被同一个人看过”《泰坦尼克号》和《阿凡达》这种超级热门片会跟几乎所有电影都有相似关系推荐结果会完全被热门片霸占。实践中要在得分计算时惩罚热门物品例如将共现次数除以电影被评分的总人数再取对数也就是所谓 IDF 加权的思想。3.4 冷启动策略和推荐结果生成冷启动是所有推荐系统绕不开的问题。新用户没有历史行为没法算相似度新电影没有评分数据没法进入推荐列表。我的处理策略有四个层次。新用户默认推荐全局热门榜。热门榜的计算公式用“贝叶斯平均”而不是单纯的平均分。单纯按平均分排一部只有 3 个人打了 10 分的片子会排第一名很假。贝叶斯平均会把评分人数纳入考量。用户产生一次评分行为后立刻用 ItemCF 做一次基于内容的补充找出他评高分电影的相似电影生成一份临时推荐列表。用户评分记录超过 5 条之后再启动完整的 UserCF 离线推荐。新电影入库后先用类型字段做一轮基于内容的召回把同类型的高评分老电影关联上确保新片有曝光机会。推荐结果写库时我会同时存生成时间和分数。接口读取时按 score 降序limit 20 条。如果 user_recommend 表为空说明用户没有进入离线推荐队列接口就自动降级到热门榜保证页面永远不会空白。这个“降级”设计非常实用很多同学做推荐系统时推荐结果为空就报错这是不应该的。任何个性化推荐系统都必须有一层兜底逻辑。4. 后端接口与前端页面联调4.1 ThinkPHP 接口怎么设计才不乱后端我用 ThinkPHP 6 搭建这样做 API 接口统一走控制器返回 JSON格式固定为{ code: 0, msg: success, data: {} }。这样约定之后前端 axios 的响应拦截器可以统一处理异常不用每个页面写重复的判断代码。接口按资源命名例如方法接口路径说明POST/api/user/login登录POST/api/user/register注册GET/api/movie/detail?id1电影详情GET/api/movie/hot热门榜POST/api/rating/add提交评分POST/api/favorite/add收藏电影GET/api/recommend/list获取个性化推荐推荐接口是核心代码很简单核心思路就是上面说的查推荐表public function list() { $userId input(post.user_id, 0, intval); if ($userId 0) { return json([code 1, msg 请先登录]); } // 优先返回个性化推荐 $list Db::name(user_recommend) -alias(ur) -join(movie m, ur.movie_id m.id) -where(ur.user_id, $userId) -order(ur.score desc) -limit(20) -select() -toArray(); // 没有推荐结果时降级为热门榜 if (empty($list)) { $list Db::name(movie) -where(rating is not null) -order(rating_count desc, rating desc) -limit(20) -select() -toArray(); } return json([code 0, data $list]); }做接口时还有几个细节值得提。敏感字段要过滤比如演员列表和简介这种很长的字段列表页接口不需要完整返回前端只显示海报和评分就够了等用户点进详情页再单独调详情接口。另外接口要考虑幂等设计收藏接口如果用户已经收藏过就不应该再次写入而要返回“已收藏”状态避免前端重复点击时产生重复记录。这些细节在答辩的时候都是加分项。4.2 Vue 前端如何组织页面和路由前端用 Vue 3 加 Vite 构建路由用 Vue Router。页面结构是典型的单页应用顶部导航栏、左侧电影分类侧边栏、右侧主要内容区。路由设计要保持清晰比如/home是首页推荐/movie/:id是电影详情/user/center是个人中心。路由路径加参数时注意类型转换params.id拿到的是字符串调接口前要用 Number 转成整型。组件拆分上我建议把电影卡片封装成一个独立的 MovieCard 组件。它接收一个 movie 对象渲染海报、标题、评分。首页推荐、热门榜、相似推荐全部复用这个组件只是传入的数据不同。这个组件化思路能让你在后期加新页面时节省大量时间。卡片链接到详情页时用 router-link 替换a标签避免整页刷新。请求层用 axios 统一封装。我在src/utils/request.js里创建了一个 axios 实例配置 baseURL 指向 ThinkPHP 的接口域名请求头默认带 token响应拦截器里判断 code 字段不是 0 就弹出错误提示。这样页面里只需要写业务逻辑不需要处理 HTTP 层的琐碎问题。一个常见的坑是跨域。前端开发时跑在本地 5173 端口ThinkPHP 跑在本地 80 或者 8080 端口浏览器会拦截跨域请求。解决方式有两个开发环境配置 Vite 的 proxy 代理把所有/api请求转发到后端地址生产环境则在后端控制器基类中设置跨域响应头。我在开发阶段用了 Vite 代理部署阶段又统一在后端加了跨域中间件前后端分离项目这两种方式都要学会。4.3 交互细节收藏、评分、播放行为如何影响推荐个性化推荐系统的数据来源就是用户行为前端页面上的每个交互组件都在为算法贡献数据。我在电影详情页里放了三个关键组件评分组件、收藏按钮、播放预告片区域。评分组件用了一个五星控件用户点击评分后前端立刻调用/api/rating/add接口把 movie_id 和 rating_value 提交给后端。收藏按钮切换收藏状态接口返回后更新本地样式。播放预告片的行为也会记录一条 history这条记录的含义是“用户对这部电影产生了强兴趣”。这些行为数据在 user_rating、user_favorite、user_history 三张表里都会留下痕迹。离线推荐脚本每天读取一遍这些表重新计算推荐结果。你会发现一个很有意思的现象你昨晚给某部电影打了高分第二天首页推荐里就会出现与该电影类型相似的作品。这种“推荐反馈的时效性”是展示系统智能感最简单有效的手段。为了让效果更明显我把离线任务设置成每 6 小时跑一次而不是每天一次。这样对用户来说推荐结果就像“实时更新”一样灵敏。4.4 可视化大屏让“大数据”可见为了把“基于大数据”这个点直观地展示给用户和评委我在前端单独做了一个数据可视化页面用 ECharts 渲染了四类图表电影类型分布饼图、评分区间柱状图、每月新片数量折线图、用户评分行为热力图。这些图表的数据来源是我在离线脚本里计算好的统计结果存到 sys_statistics 表里接口直接查表返回给前端。前端用 Vue 的 watch 监听数据变化更新 ECharts 实例的 option。可视化页面的价值是双重的。对用户而言它提供了探索数据的入口对答辩而言它把“大数据”这个概念直观地呈现在屏幕上比任何文字介绍都有说服力。实现时有一点要提醒ECharts 实例在组件销毁时要调用 dispose 方法释放内存否则页面切换次数多了会导致内存占用过高。Vue 的 onUnmounted 钩子里处理这一点。5. 常见问题与排查心得5.1 爬虫采集时经常被拒绝访问或者返回空数据这个问题我遇到过很多次一开始以为是代码写错了后来发现大多是请求头太像爬虫了。解决方式很简单请求头里带上浏览器 User-Agent、Referer再带上 Accept-Language。不要让请求频率超过一分钟 30 次一旦被拒绝就直接退避停止采集一小时后重试而不是立刻重试。如果发现某个页面结构变化导致解析不到数据先看响应 HTML 的实际情况再改解析规则。记住爬虫只是辅助工具本项目重点是推荐算法不要在爬虫对抗上花太多精力遵守站点公开协议和访问限制是对双方都负责的做法。5.2 推荐结果不准或者出现为空的情况先检查数据。如果用户行为表里只有十条记录任何算法都救不回来。我当时的办法是给测试账号自动生成一批模拟评分数据数量控制在 30 条以上再跑推荐脚本这样能明显看到算法效果。数据量足够却依然不准就看相似度计算是否正常UserCF 的结果有没有被热门电影霸占如果所有用户的推荐列表都一样多半是相似度没有正确归一化热门电影在共现矩阵里权重太高。用 ItemCF 时记得加入热门惩罚项。另外我强烈建议在开发阶段把推荐脚本的中间结果打印出来例如打印相似度最高的五个用户、他们共同看过的电影、每个候选电影的加权得分。这一步能看到算法每个环节的输入输出排查效率比直接看最终推荐列表高很多。脚本跑完可以生成一份简单的验证报告统计推荐结果的类型分布确认是否真的“个性化”。5.3 查询速度慢索引与缓存数据量刚过万时查询速度还能接受但如果用户行为表增长到十几万条关联查询就开始变慢了。排查方法是给 MySQL 的表加索引。我在 user_rating 表的 user_id 字段、user_favorite 表的 user_id 字段、user_recommend 表的 user_id 字段都加了普通索引。movie 表的 id 是主键天然有索引。加了索引之后推荐接口的响应时间从两百多毫秒降到二十毫秒以内。索引不是越多越好只在 WHERE 和 JOIN 条件上建。热榜数据我做了缓存。每分钟访问量很大的接口例如首页热榜我把查询结果缓存到 ThinkPHP 的 Cache 里缓存时间设置为 600 秒。用户量大的项目中这个优化能降低七八成数据库压力。推荐结果表是离线写好的本身就不存在实时查询负担所以系统整体的性能瓶颈主要在前端资源和图片加载给静态资源配置好 CDN 或者至少开启浏览器缓存体验会好很多。5.4 环境搭建和部署阶段的问题ThinkPHP 项目本地跑不起来十有八九是伪静态没配好。Apache 要开启 mod_rewrite 模块Nginx 要配置对应的 rewrite 规则指向入口文件 index.php。Vue 项目构建后是一堆静态文件部署时直接扔到 Nginx 的静态目录然后配置一个 location 规则把所有非 API 的请求都转发到 index.html保证刷新页面时路由不 404。这个配置不复杂但很多初学者卡在这。跨域问题的另一种表现是上线后接口能通但经常有 OPTIONS 预检请求失败。原因是自定义请求头触发了浏览器预检。我在后端的跨域中间件里除了加 Access-Control-Allow-Origin还加了 Access-Control-Allow-Headers 和 Access-Control-Allow-Methods并且对 OPTIONS 请求直接返回 200 空响应这样预检请求不会阻塞实际请求。MySQL 字符集一定要设置成 utf8mb4否则电影简介里的表情符号和特殊字符会直接报错或者变成问号。这个问题我栽过跟头后来规范了建库语句默认字符集全部用 utf8mb4排序规则用 utf8mb4_general_ci。类似的静态资源问题图片路径建议存相对路径不要存绝对 URL。本地开发环境路径和线上环境不同存绝对 URL 会导致图片全部加载失败。5.5 给后来者的几点建议把项目拆成四个阶段推进会很有节奏感。第一阶段专注环境和数据跑通 ThinkPHP 接口、建好数据库、完成爬虫采集至少 5000 条电影数据。第二阶段专注推荐算法实现 UserCF 离线计算把推荐结果落库。第三阶段专注前端交互Vue 页面完工所有接口联调通过。第四阶段专注效果打磨生成模拟用户行为、调算法权重、完善可视化页面。答辩或者展示前准备一套演示数据是明智的选择。我预先创建了三个不同偏好的测试账号一个喜欢科幻、一个喜欢爱情片、一个喜欢纪录片然后为它们批量生成模拟评分。演示当天切换账号时推荐结果差异非常明显效果比只讲空头概念强得多。演示时还要准备一个电影数量足够多的页面快速滚动时让页面保持流畅给评委留下好印象。这个系统后续如果还想继续扩展我建议补两个方向。一个是引入搜索引擎让用户能按关键词搜索电影搜索记录也能成为推荐算法的输入信号。另一个是引入更细粒度的用户画像例如根据观看时长把用户按活跃度分群对不同群组使用不同的推荐策略。目前这套代码里预留了推荐策略字段后续要加策略扩展也比较方便。我在实际开发中最深的体会是推荐系统不需要一开始就上多复杂的算法先把数据链路跑通把基础推荐做稳定再逐步叠加优化。这套思路放在 ThinkPHP 和 Vue 的项目里也同样成立框架只是工具真正有价值的是你想清楚每一层数据的流向以及每一步逻辑背后的理由。