简介:这是一款面向计算机专业毕业设计场景的动漫推荐系统微信小程序源码,覆盖用户端与管理员端完整功能。用户端支持主页、分类、热门、最新、搜索、个性化推荐、资讯、论坛讨论及个人中心;管理员后台可对用户、资讯、漫画分类、漫画内容和论坛讨论进行统一管理。项目基于微信开发者工具、Java与MySQL实现,前端页面、业务逻辑与数据库脚本齐全。包内共3267个文件,以png、svg等图片素材,css、html、js等前端页面与交互逻辑,以及java、json、sql等后端和配置类文件为主,压缩包约16.59MB,结构清晰便于查阅与二次开发。目前已有106人学习浏览,适合需要参考完整毕设项目、课程设计或快速上手微信小程序与Java后端整合的读者。
1. 动漫推荐系统小程序:别让“推荐”两个字卡住你的毕设答辩
动漫推荐系统小程序这类毕业设计,最后被老师追问最多的地方往往不是页面做得漂不漂亮,而是“推荐到底怎么算出来的”。网上下载的源码通常能跑,首页也能刷出几张动漫卡片,可一旦被问到用什么算法、冷启动怎么处理、效果怎么验证,很多人当场卡壳。
这套项目的本质是三层链路:微信小程序负责界面和采集评分,后端负责内容召回与协同过滤排序,最后再用热度榜做兜底。下面按这个顺序拆开讲,从算法选型、接口实现,到排错和答辩前的离线评估,把它变成你能讲明白、也敢被追问的毕设。
适合用原生小程序做前端、后端打算用 Python 轻框架或 Spring Boot 接推荐逻辑的读者。数据量不用大,几百部番、几千条评分,就足够撑起一个逻辑完整的推荐系统。
2. 选推荐算法再写代码:热门榜、内容召回与协同过滤的三层结构
推荐系统在毕设里很容易被当成黑匣子,其实不玄学。常见做法是把它拆成三层:召回、排序、兜底。召回解决“从几百部番里先捞出一个候选集”,排序解决“候选集里谁排前面”,兜底解决“新用户没有任何行为时给他看什么”。这个漏斗结构不管后端用什么框架都成立,同类毕设里的基于 Spring Boot 的大学生就业推荐系统、Ubuntu 下的电影推荐小项目,核心都是同一套骨架,只是物品从动漫换成了岗位或电影。
2.1 毕设推荐系统该选什么技术栈:原生小程序加轻后端就够了
先回答最现实的选型问题。前端建议用原生微信小程序,而不是 uni-app 这类跨端框架:原生调试路径短,开发者工具打开就能跑,能搜到的源码和教程也更多。后端我一般用 Python 的 Flask 或 FastAPI 配 SQLite,理由有三个:一是 Python 在推荐算法方向的源码资料非常多,从入门级项目到带评估脚本的完整工程都能找到参考;二是 SQLite 单文件部署,答辩演示时不用额外起数据库服务;三是同样的接口逻辑,事后想改成 Spring Boot 交付也不难,把算法步骤翻译成 Service 层方法就行。
数据量级先定下来:几百部动漫、几十个测试用户、几千条评分。这个规模在普通笔记本上跑推荐接口,耗时可以忽略不计,完全没必要上 Spark、Hadoop 那套。深度学习版本虽然听起来更有噱头,但训练语料、显卡和调试时间都是成本,在毕设周期里很容易翻车。先把三层漏斗跑通,就已经是一个逻辑完整、能自圆其说的推荐系统。
2.2 三层漏斗架构:召回、排序、热度兜底各自解决什么问题
召回层关心“有没有”。用基于内容的方式最省事:给每部动漫存一组标签,比如“热血、冒险、治愈”,用户历史评分里高频出现的标签就是他的偏好画像,拿画像去匹配全部动漫,按命中比例捞回 Top N。这个阶段不需要协同过滤,因为协同过滤依赖“喜欢 A 的用户也喜欢 B”,在数据稀疏时很容易捞回一堆零共现的物品。
排序层关心“合不合理”,协同过滤在这里上场。毕设场景里 item-based CF(基于物品的协同过滤)比 user-based CF 更合适:物品数量远小于用户数量,相似度矩阵规模可控,而且解释起来很直观——“跟你喜欢的那部番相似的其他番”。核心是统计两两物品之间的共现关系,用户对 A 高分、同时也对 B 高分,A 和 B 的相似度就上升;预测时拿用户评过分的物品按相似度加权,得到候选物品的预估分。
兜底层关心“下限够不够体面”。新用户没评过任何一部番,协同过滤就返回空列表,热度榜这时负责兜住体验,用播放量、评分人数和发布时间算一个热度分。这层代码量不大,但能不能想到它,往往是答辩时区分“真懂推荐”和“只会跑源码”的关键。
| 漏斗层 | 输入 | 输出 | 对应模块 |
|---|---|---|---|
| 召回 | 用户历史评分 + 动漫标签 | Top 50 候选动漫 ID | content_based.py |
| 排序 | 候选集 + 物品相似度矩阵 | 按预测分降序的列表 | item_cf.py |
| 兜底 | 播放量、评分、发布时间 | 热度分降序的默认列表 | hot.py |
2.3 先把数据表建出来:anime、user、rating 的最小结构
动手写算法前,先把表建出来。下面按 SQLite 方言写,换成 MySQL 只需要改少量类型。
-- schema.sql CREATE TABLE IF NOT EXISTS anime ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, tags TEXT NOT NULL, -- 逗号分隔,例如 "热血,冒险,搞笑" summary TEXT, cover_url TEXT, views INTEGER DEFAULT 0, avg_rating REAL DEFAULT 0, rating_cnt INTEGER DEFAULT 0, publish_ts INTEGER DEFAULT 0 -- unix 时间戳,热度衰减用 ); CREATE TABLE IF NOT EXISTS user_info ( id INTEGER PRIMARY KEY AUTOINCREMENT, openid TEXT UNIQUE NOT NULL, -- 微信登录换取的真实用户标识 nickname TEXT, created_ts INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS rating ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, anime_id INTEGER NOT NULL, score INTEGER NOT NULL, -- 1~5 分 created_ts INTEGER DEFAULT 0, UNIQUE(user_id, anime_id) -- 同一用户对同一部番只保留一条评分 );评分表加UNIQUE(user_id, anime_id)是必须的,否则同一用户反复提交评分会出现多条记录,协同过滤的共现统计会被污染。实际写入时用INSERT ... ON CONFLICT DO UPDATE实现“评一次、改分可覆盖”。publish_ts存 unix 时间戳而不是字符串,热度衰减做减法运算时少踩一个坑,我用过字符串时间做日期比较,排序结果乱到只能怀疑人生。avg_rating和rating_cnt不需要实时聚合,每次评分写入后顺手更新这两个字段,后面热度分公式直接拿现成值。
初始化数据时还要注意覆盖度。不要只填热门番,故意留几部只有个位数评分的长尾番,这样演示召回和协同过滤时才能看到“相似推荐”,否则热门榜永远占据推荐结果,算法层形同虚设。数据来源可以用公开动漫条目手工整理 50 部典型番,标注好标签和简介,毕设量级足够。
3. 小程序端从零跑通:推荐流页面、评分交互与列表加载更多
后端算法可以慢慢调,小程序端先按经典结构搭:首页推荐流、搜索页、详情页、个人中心。其中首页推荐流和评分交互直接决定用户行为数据有没有进库,是整个推荐系统的入口;列表加载更多则是真机上最先暴露问题的地方。
3.1 请求封装与首页推荐流:用 wx.request 拉接口的最小实现
先把网络层封装成一个 Promise 方法,避免每个页面重复写成功失败回调。
// utils/request.js const BASE_URL = 'https://your.domain.com/api' function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method, data, header: { 'Content-Type': 'application/json' }, success(res) { if (res.statusCode === 200 && res.data.code === 0) { resolve(res.data.data) } else { reject(new Error(res.data.message || '请求失败')) } }, fail(err) { reject(err) } }) }) } module.exports = { request }返回值统一从res.data.data解包,是因为后端接口固定返回{ code: 0, message: 'ok', data: ... }这样的 JSON 壳。code !== 0统一走 reject,页面只需要 catch 一次就能弹 toast,不用每个接口重复判断状态码。BASE_URL在开发阶段可以用本地 IP,但真机预览时必须换成备案域名的 HTTPS 地址,否则会遇到 403 或request:fail,这个坑在第 5 章展开。
首页在onLoad里请求一次推荐接口,渲染一个带 loading 态的卡片流:
// pages/index/index.js const { request } = require('../../utils/request') Page({ data: { list: [], loading: false, page: 1, pageSize: 10, finished: false }, onLoad() { this.loadRecommend(true) }, async loadRecommend(reset) { if (this.data.loading) return if (reset) { this.setData({ page: 1, finished: false }) } this.setData({ loading: true }) try { const data = await request('/recommend', 'GET', { user_id: wx.getStorageSync('user_id'), page: this.data.page, page_size: this.data.pageSize }) const list = reset ? data.items : this.data.list.concat(data.items) this.setData({ list, loading: false, finished: this.data.list.length >= data.total || data.items.length === 0 }) } catch (e) { this.setData({ loading: false }) wx.showToast({ title: '推荐加载失败', icon: 'none' }) } }, onReachBottom() { if (this.data.finished || this.data.loading) return this.setData({ page: this.data.page + 1 }, () => { this.loadRecommend(false) }) } })这里有两个参数直接影响推荐效果:page_size我习惯固定 10,超过 10 首屏加载时间会明显拉长,而且推荐流本来就不需要一次给太多;reset为 true 时把finished重置,避免下拉刷新后触底判断还是旧状态。列表加载更多用小程序原生的onReachBottom,不需要额外加滚动监听,但真机上这个回调可能触发得很频繁,loading这个闸门不能省。
提示:开发模式可以在开发者工具里勾选“不校验合法域名”,但体验版和正式版仍然强制校验,上线前一定去公众平台配置域名。
3.2 评分交互:把用户反馈送进协同过滤的燃料箱
协同过滤没有评分行为就是空转。详情页放一组 1~5 星评分按钮,提交时调用/rating接口,前端先做乐观更新。
// pages/detail/detail.js const { request } = require('../../utils/request') Page({ data: { anime: null, myScore: 0, submitting: false }, async submitScore(e) { const score = e.currentTarget.dataset.score if (this.data.submitting) return // 防止连点 this.setData({ submitting: true, myScore: score }) // 先改 UI try { await request('/rating', 'POST', { user_id: wx.getStorageSync('user_id'), anime_id: this.data.anime.id, score }) wx.showToast({ title: '评分成功', icon: 'success' }) } catch (err) { this.setData({ myScore: 0 }) // 失败回滚 wx.showToast({ title: '评分失败', icon: 'none' }) } finally { this.setData({ submitting: false }) } } })乐观更新的意思是先让星星亮起来,再等后端确认;接口失败时把myScore回滚成 0,用户感知比一直转圈友好。这里特别提醒:user_id不要前端自己生成,规范做法是用wx.login拿 code 去后端换 openid,再返回一个稳定的用户 ID。如果图省事直接存随机数,用户换个设备评分数据就丢了,协同过滤矩阵全是孤立点。
3.3 列表加载更多:onReachBottom 触底翻页的参数与坑
加载更多的三分支逻辑在 3.1 已经写出,这里单独说参数和边界。finished的计算有两种:后端返回total时用list.length >= total;后端不返回时,就靠items.length === 0判断。建议后端两种都返回,前端优先用 total,因为 items 为空要等用户翻到最后一页才会请求一次空数据,体验略差。
另外,真机上用setData追加列表时,把整个新数组塞进去,数据量一大就掉帧。保险做法是控制单次新增不超过 20 条,图片加上lazy-load属性。小程序官方建议单次 setData 数据尽量控制在 1024KB 以内,推荐卡片的封面图和描述重复渲染也是开销,图片最好压缩后再入库。
4. 推荐服务端落地:内容召回、物品协同过滤与热度兜底的实现
服务端是答辩时的主战场。我的习惯是先写三个独立模块——content_based.py、item_cf.py、hot.py,再在路由里按“召回 → 排序 → 兜底”的顺序组合。组合顺序很重要:先把候选集捞出来,再用协同过滤打分,最后用热度榜填空。
4.1 内容召回:用标签画像给每个用户捞候选集
内容召回不挑数据,几百条标签记录就能跑。下面是最小实现:
# recommend/content_based.py from collections import defaultdict def build_user_tags(rating_rows, top_k=5): """rating_rows: [(user_id, anime_id, score, tags_str)]""" tag_weight = defaultdict(float) for uid, aid, score, tags_str in rating_rows: tags = set(tags_str.split(',')) weight = 1.0 if score <= 3 else float(score) / 5.0 for tag in tags: tag_weight[tag] += weight return {tag: w for tag, w in sorted(tag_weight.items(), key=lambda x: x[1], reverse=True)[:top_k]} def recall_by_tags(user_tags, anime_list, top_n=50): """anime_list: [{id, title, tags_str, ...}]""" scores = {} for item in anime_list: item_tags = set(item['tags_str'].split(',')) hit = sum(user_tags.get(t, 0.0) for t in item_tags) if hit > 0: # 除以物品标签数,避免标签多且杂的番永远占优 scores[item['id']] = hit / max(len(item_tags), 1) ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [aid for aid, _ in ranked[:top_n]]build_user_tags里对评分做了加权,高分标签权重更大;top_k=5控制用户画像的标签数量,取太多画像会稀释。recall_by_tags里分母除以len(item_tags),是我调参时踩出来的:动漫标签个数差异很大,有的 3 个有的 10 个,不归一化的话长标签动漫会被无限拔高。top_n=50是召回层的宽度,后面排序层会在这个候选集里继续筛。
4.2 item-based CF 排序:共现矩阵、余弦相似度与 top-k 邻居
接下来是核心排序模块。我给一版不依赖第三方库、千条评分级别够用的实现:
# recommend/item_cf.py import math from collections import defaultdict MIN_SCORE = 4 # 只看 4 分及以上的高评价,过滤中性行为 NEIGHBOR_K = 10 # 每个用户取 top-10 相似邻居 EPSILON = 1e-6 # 防止除零 def build_item_sim(ratings): # ratings: [(user_id, anime_id, score)] item_users = defaultdict(set) for uid, aid, score in ratings: if score >= MIN_SCORE: item_users[aid].add(uid) items = list(item_users.keys()) sim = {} for i in range(len(items)): a = items[i] users_a = item_users[a] for j in range(i + 1, len(items)): b = items[j] users_b = item_users[b] inter = len(users_a & users_b) if inter == 0: continue s = inter / math.sqrt(len(users_a) * len(users_b)) sim.setdefault(a, {})[b] = s sim.setdefault(b, {})[a] = s return sim def predict_score(user_rated, anime_id, sim, k=NEIGHBOR_K): neighbors = [] for aid, score in user_rated.items(): if aid in sim.get(anime_id, {}): neighbors.append((sim[anime_id][aid], score)) if not neighbors: return None neighbors.sort(key=lambda x: x[0], reverse=True) neighbors = neighbors[:k] num = sum(w * s for w, s in neighbors) den = sum(w for w, _ in neighbors) + EPSILON return num / den共现矩阵的统计只看高分行为,这是刻意的。3 分和 5 分对“是否喜欢”的区分度完全不同,混在一起算相似度会让结果偏向大众番。MIN_SCORE=4表示只把“我喜欢”当成正反馈,这也是为什么详情页评分组件必须鼓励用户真实打分。预测阶段用den + EPSILON防除零,NEIGHBOR_K=10控制邻居数量,太大反而会把不相关物品的噪声带进来。
实际组合时,item_cf会在召回得到的top_n=50候选集里逐个调用predict_score,拿不到相似邻居的物品直接丢弃,最后按预测分降序返回给前端。
4.3 热度兜底:给新用户和新番一个体面的默认推荐
没有评分行为的用户直接走热度榜。热度分不是简单播放量排序,我习惯把播放量、评分、评分人数和时间衰减一起算:
# recommend/hot.py import time def hot_score(views, avg_rating, rating_cnt, publish_ts, half_life_days=7.0, now=None): now = now or time.time() age_days = (now - publish_ts) / 86400.0 # 新番给一点时间加成,避免刚发布就沉底 boost = 1.5 if age_days < 7 else 1.0 decay = 0.5 ** (age_days / half_life_days) quality = 0.6 * avg_rating + 0.4 * min(rating_cnt / 50.0, 1.0) * 5 return boost * (views * decay + quality * 20)这个公式里两个参数值得解释。half_life_days=7让一周前的播放量权重衰减一半,这样召回和排序结果里的老番不会永远压在顶部;min(rating_cnt / 50.0, 1.0)是对评分人数的置信度约束——只有 2 个人打出 5 分的高分,不应直接压过有 200 人打出 4.6 分的成熟热门。publish_ts缺失或为 0 的旧数据,age_days会非常大,decay 趋近 0,热度分主要由质量项撑住,不会直接变成 0 分。
注意:热度榜接口返回条数建议限制在前 30,超出之后用户根本划不到,白白浪费服务端计算。
5. 常见问题与排错手册:白屏、接口 403 与推荐不更新的五个坑
这一章全是反复被踩出来的坑,每一条按“现象、原因、解决”的顺序说清楚。真机上的问题往往比开发者工具里多,遇到问题先看工具面板的 Network 和 Console,再看后端日志,顺序不要反。
5.1 真机白屏、接口 403:合法域名与 HTTPS 的配置顺序
现象:开发者在工具里一切正常,点“预览”用手机扫码打开,首页白屏,Console 打出request:fail url not in domain list,或者直接 403,有时报 10002 错误码。
原因:小程序真机环境强制校验合法域名,开发工具里“不校验合法域名”的勾选只管本机调试;10002 多数是域名不在白名单或证书链不完整。
解决:后端接口先配 HTTPS 证书,再把域名(不带路径)加到微信公众平台的“服务器域名”白名单里。证书中间链也要补全,有些云厂商默认只发叶子证书,浏览器能访问但小程序校验会失败。保存后到开发者工具点“清缓存并重新编译”。这里经常有人顺序反了,先改代码后发现没用,实际是域名校验没生效。另外注意个人主体和小程序认证状态会限制服务器域名配置,答辩前先确认主体类型能配 HTTPS 域名。
5.2 推荐结果永远不更新:评分没落库还是缓存没清
现象:用户评了 5 分,详情页已提示成功,但首页推荐两三天都不变。
原因:先查评分表有没有新记录,最常见的是UNIQUE(user_id, anime_id)触发冲突,第二次改分被旧数据处理逻辑覆盖;其次是后端接口读了进程内缓存,而写入没有做缓存失效。
解决:先在 SQLite 里查SELECT user_id, anime_id, score, created_ts FROM rating WHERE anime_id=12 ORDER BY created_ts DESC LIMIT 20;,确认有没有落库。再检查接口里缓存 key,推荐列表的缓存建议按user_id + page做 key,超时控制在 5 分钟以内,并且评分写入后主动清一次对应用户的缓存。这里没有玄学,顺着链路一层层查,五分钟就能定位。
5.3 新用户看到的推荐毫无逻辑:冷启动兜底没接
现象:注册一个新微信号登录小程序,首页推荐和热门榜完全无关,全是评分人数最少的小众番。
原因:新用户没有任何评分,内容召回和协同过滤都拿不到画像,如果路由层没判断“无行为用户就返回热度榜”,算法就会用稀疏矩阵硬算,得出一个看似随机的结果。
解决:在/recommend接口开头检查该user_id的评分数量,为 0 就直接返回hot.py的结果。分支逻辑大概是:
# routes/recommend.py def recommend(user_id, page, page_size): rating_cnt = get_rating_count(user_id) if rating_cnt == 0: return hot_list(page, page_size) candidates = recall_by_tags_for_user(user_id, top_n=50) ranked = rank_by_item_cf(candidates, user_id) return ranked[page * page_size: (page + 1) * page_size]这个冷启动分支要作为独立逻辑写清楚,答辩时老师问“新用户怎么处理”,正好展开讲为什么需要热度兜底。
5.4 触底翻页卡顿和重复数据:setData 合并的细节
现象:真机上向下滑动加载更多,列表明显卡顿,有时同一页数据出现两次。
原因:onReachBottom触发后没有拦住重复请求,或者setData用整数组一次性渲染,数据量大时掉帧;重复数据通常是后端分页参数算错了,第二次请求提交了相同的page。
解决:前端在触底回调里同时判断finished和loading,两个条件满足任意一个就 return;后端分页用OFFSET (page-1)*page_size LIMIT page_size,不要把page直接当offset用。3.1 里的写法就是这两个闸门的标准结构,照抄能少走一半弯路。
5.5 抓不到小程序的请求:charles 与 reqable 抓包前置操作
现象:想用 charles 观察小程序的请求,结果手机上打开小程序没有流量记录,但微信聊天正常。
原因:小程序默认走 HTTPS 且有证书校验,直接监听端口拿不到明文,需要先安装根证书并保证请求域名和抓包配置一致。
解决:经典做法是电脑监听一个本地端口,手机连同一 Wi-Fi,把请求指向该端口,再安装抓包工具根证书。reqable 也支持 PC 端直接抓小程序包,界面比 charles 友好一些。抓包不是必选项,接口字段对不上或状态码异常时再上,优先级永远低于后端日志。
6. 答辩前怎么证明推荐有效:离线评估脚本与体验版试用的最后一关
代码写完了、推荐也接上了,但答辩时最容易被追问的是“你的推荐效果凭什么说好”。我的习惯是准备两条证据链,一条靠离线评估,一条靠真实试用。
6.1 离线评估:随机留出评分算 precision@k 与 RMSE
基于历史评分,随机留出 20% 作为测试集,用剩余 80% 训练相似度,然后看推荐结果能不能命中测试集里的真实评分物品:
# evaluate/offline.py import random from collections import defaultdict def split_ratings(ratings, test_ratio=0.2, seed=42): random.seed(seed) train, test = [], [] for r in ratings: (test if random.random() < test_ratio else train).append(r) return train, test def precision_at_k(train, test, recommend_func, k=10): test_by_user = defaultdict(list) for uid, aid, _ in test: test_by_user[uid].append(aid) hit, total = 0, 0 for uid, items in test_by_user.items(): recs = recommend_func(uid, k)[:k] if set(recs) & set(items): hit += 1 total += 1 return hit / max(total, 1)precision@10的含义是“给用户推荐的 10 部番里,至少命中一部他真实看过且评过高分的比例”。test_ratio=0.2表示留出 20% 做测试,seed=42保证可复现。实验时先跑纯热门榜的 precision,再跑“召回 + 协同过滤”的 precision,两张数字一对比,算法增益立刻显出来,比空口说“效果不错”有说服力得多。
6.2 体验版实测:把小程序发给同学,看行为日志里的真实数据
在线验证比离线评估更能暴露体验问题。在微信开发者工具点“上传”,到公众平台把这次上传设为体验版,再勾选几个微信成员的微信号,对方扫码就能试用,这就能收集到真实的行为日志。看数据时重点盯两个数:首屏曝光到详情页点击的转化率,以及详情页到评分提交的转化率。首屏曝光转化率低于 10%,多半是封面图或标题展示有问题;评分提交太少,协同过滤就一直是空转,需要给评分加引导。
我的血泪经验是:有次删掉冷启动兜底做实验,结果新用户首屏推荐全是评分次数最少的番,同学试用后原话是“这推荐是不是坏了”。把热度榜加回新用户分支后,首屏到详情页的转化率立刻从 6% 涨回 15%。评估代码再漂亮,也不如让一个真实新用户点两下页面。最后留一个调试习惯:每次改完相似度参数,把评分表清空重导一遍,确保离线评估和线上结果用的是同一份数据,别等答辩前夜才发现两边对不上。希望帮到你。
本文还有配套的精品资源,点击获取