Spring Boot + Vue 图书个性化推荐系统:协同过滤与混合推荐实践
2026/9/16 6:06:13 网站建设 项目流程

简介:这是一套基于 Spring Boot 与 Vue 的图书个性化推荐系统完整源码,属于高分毕业设计项目,主要面向计算机相关专业正在筹备毕业设计的学生,同时也可作为课程设计或期末大作业。资源以 zip 压缩包形式提供,共包含 769 个文件,涵盖 Java 后端逻辑、Vue 前端组件、JavaScript 脚本、CSS 样式、HTML 页面及环境配置等类型,压缩包整体约 14.67MB,目录层次清晰,便于快速定位与二次开发。项目采用前后端分离架构,前端使用 Vue 构建页面交互,后端使用 Spring Boot 提供数据接口,代码经过导师指导与严格调试,运行稳定,可直接部署使用。目前已有 206 人浏览学习,对需要完整项目参考的初学者和毕设学生而言,这是一份高性价比的实战材料,既能直接用于答辩展示,也能从中学到图书推荐功能的前后端实现思路与联调方法。

1. 图书个性化推荐不是把热门书放前面那么简单

图书推荐在图书馆系统、学习平台和电商场景里一直很有话题度:书籍是典型的长尾商品,头部畅销书只有几十本,真正留住用户的往往是几本冷门但精准的小众书。这决定了图书个性化推荐系统不能靠热门榜刷存在感,它要在稀疏的用户行为矩阵里找出“你读过《人类简史》就该试试《未来简史》”这条隐性路径。标题里这套 Spring Boot + Vue 的源码,本质是用单体架构把推荐算法、后端接口和前端展示完整串起来,既能在毕设答辩时讲清楚推荐从哪来,也能当作可运行的工程写进简历。适合正在选方向的计算机专业学生,也适合想快速了解推荐系统落地细节的后端工程师。文章按算法选型、后端实现、前端交互、联调验证四步推进,末尾补两个可现场演示的技巧。

2. 推荐算法怎么选:协同过滤、内容过滤还是混合召回

2.1 三类算法的适用边界与数据要求

标题写的是“个性化推荐”,没有限定算法,这正好给了方案自由度。常见的做法有三种:基于用户的协同过滤(UserCF)把与目标用户行为相似的用户读过的书推荐出来,但冷启动用户基本无解;基于内容的过滤按图书分类、标签、作者等特征找相似书籍,新书能立刻被推荐,可一旦脱离用户行为就谈不上个性化;混合推荐把两类结果按权重合并,必要时叠一层热门兜底,这是毕设和中小型系统里最稳妥的组合。

选型判断依据很好记,直接看这组对照:

算法需要的数据弱项适合场景
UserCF用户-图书评分/行为矩阵冷启动、矩阵稀疏有几千条以上行为记录
ItemCF用户行为 + 图书相似度矩阵流行度偏差用户量小、书籍量大的场景
内容过滤图书标签/分类/作者同质化,缺乏惊喜度新书推荐、冷启动覆盖

图书推荐的核心矛盾是大多数用户只对几本书留下行为,矩阵稀疏度通常超过 98%。因此我一般不会把 UserCF 单独跑,而是让它和内容过滤并行出结果,再用规则合并。混合策略正是标题里“个性化”三个字能立住的原因,答辩时先讲透算法选型,比堆一堆接口代码更能拿分。

2.2 用 Java 写一个可复现的 UserCF

下面的纯 Java 实现不依赖外部推荐库,放进service/recommend/UserBasedCF.java就能跑,也方便答辩时逐行解释。

public class UserBasedCF { // 用户 id -> (图书 id -> 行为分数) private final Map<Integer, Map<Integer, Double>> userBookScores; public UserBasedCF(Map<Integer, Map<Integer, Double>> userBookScores) { this.userBookScores = userBookScores; } // 计算两个用户的皮尔逊相关系数 private double pearson(Map<Integer, Double> u1, Map<Integer, Double> u2) { Set<Integer> common = new HashSet<>(u1.keySet()); common.retainAll(u2.keySet()); if (common.size() < 2) return 0.0; double sum1 = 0, sum2 = 0, sq1 = 0, sq2 = 0, dot = 0; for (Integer bookId : common) { double a = u1.get(bookId), b = u2.get(bookId); sum1 += a; sum2 += b; sq1 += a * a; sq2 += b * b; dot += a * b; } int n = common.size(); double denom = Math.sqrt((sq1 - sum1 * sum1 / n) * (sq2 - sum2 * sum2 / n)); return denom == 0 ? 0.0 : (dot - sum1 * sum2 / n) / denom; } // 找最相似的 k 个用户,返回 topN 个图书及预测分 public Map<Integer, Double> recommend(int targetUserId, int k, int topN) { Map<Integer, Double> target = userBookScores.get(targetUserId); Map<Integer, Double> simMap = new HashMap<>(); for (Map.Entry<Integer, Map<Integer, Double>> e : userBookScores.entrySet()) { if (e.getKey().equals(targetUserId)) continue; double sim = pearson(target, e.getValue()); if (sim > 0) simMap.put(e.getKey(), sim); } List<Map.Entry<Integer, Double>> neighbors = simMap.entrySet().stream() .sorted(Map.Entry.<Integer, Double>comparingByValue().reversed()) .limit(k).collect(Collectors.toList()); Map<Integer, Double> scores = new HashMap<>(); for (Map.Entry<Integer, Double> n : neighbors) { int neighborId = n.getKey(); double sim = n.getValue(); for (Map.Entry<Integer, Double> b : userBookScores.get(neighborId).entrySet()) { int bookId = b.getKey(); if (target.containsKey(bookId)) continue; // 已读过的书不再推荐 scores.merge(bookId, sim * b.getValue(), Double::sum); } } return scores.entrySet().stream() .sorted(Map.Entry.<Integer, Double>comparingByValue().reversed()) .limit(topN) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new)); } }

这段代码的调参点是ktopNk是邻居用户数,数据量在几千条时取 20 比较稳定,太小容易受噪声用户干扰,太大又会把弱相关用户拉进来;topN是最终推荐条数,前端图书墙一般展示 10 到 20 本。common.size() < 2是为了避免只有一条共同行为时算出虚假的高相似度。行为分不是直接用评分,而是把浏览、收藏、评分按 2.4 节的权重折算,这套“打分前先统一口径”的思路在推荐系统里比算法本身更容易被忽略。

2.3 内容过滤:用标签向量算图书相似度

UserCF 只解决“相似用户读过什么”,内容过滤解决“这本书和你看过的书是否同源”。给每本书建一个标签向量,比如《人类简史》对应[历史, 社科, 文明, 通俗读物],《未来简史》对应[历史, 科技, 社科, 未来学],再用余弦相似度计算。

public double cosineSimilarity(Map<String, Double> bookA, Map<String, Double> bookB) { Set<String> common = new HashSet<>(bookA.keySet()); common.retainAll(bookB.keySet()); double dot = 0, normA = 0, normB = 0; for (Map.Entry<String, Double> e : bookA.entrySet()) normA += e.getValue() * e.getValue(); for (Map.Entry<String, Double> e : bookB.entrySet()) normB += e.getValue() * e.getValue(); for (String tag : common) dot += bookA.get(tag) * bookB.get(tag); if (normA == 0 || normB == 0) return 0.0; return dot / (Math.sqrt(normA) * Math.sqrt(normB)); }

图书标签优先从标签表里读,没有标签时用书名分词结果兜底。历史这类高频标签会拉高所有历史书的相似度,导致推荐结果同质化,所以要对高频标签做 IDF 加权,即把标签权重除以 log(出现次数)。这块的工程量和算法本身一样大,标签表建得随意,后续算法怎么调都白搭。推荐系统的核心不在高深框架,而在数据整理,这个认知放到简历面试里也很加分。

2.4 行为权重与冷启动下限

推荐系统通常不只依赖评分,还要把隐式反馈统一折算成行为分。以下映射是毕设项目里常用的:

行为类型行为分说明
搜索后点击详情1.0兴趣最弱,但量最大
收藏 / 加入书架4.0强意图信号
评分(1~5 分)直接取分数显式反馈
借阅 / 下载6.0最强行为

冷启动是答辩必问的点,我的处理方式是:新用户没有行为时走热门兜底,即按借阅次数和评分人数倒序;新书上架先靠内容过滤进入候选池,等积累 5 条以上行为后再进入协同过滤计算。这条必须写进系统设计说明,否则评委一句“新用户怎么办”就能让方案整体降档。

3. Spring Boot 后端:把算法结果变成可用的推荐服务

3.1 数据库表结构设计的最小集合

图书系统最少要五张表:用户表、图书表、用户行为表、图书标签表、推荐结果缓存表。行为表是推荐算法的原料,它的设计直接决定后期能不能算。

CREATE TABLE `user_behavior` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '用户ID', `book_id` bigint NOT NULL COMMENT '图书ID', `behavior_type` tinyint NOT NULL COMMENT '1浏览 2搜索 3收藏 4评分 5借阅', `score` double DEFAULT NULL COMMENT '评分行为时的分值', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_book` (`user_id`, `book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户行为流水表';

这里两个关键决策:一是行为不分表,而是用behavior_type区分,统一承载浏览、收藏、评分等动作,算法层只需要对一张表做聚合;二是建了(user_id, book_id)联合索引,因为协同过滤的起点就是查某个用户的所有行为。用户读过的书要排除在推荐外,SQL 里用NOT EXISTS过滤即可,不要直接把行为记录删掉,否则推荐池会越来越小。

推荐结果缓存表解决“每次请求都现算协同过滤”的问题。常见做法是每天凌晨用定时任务算一次全量结果,白天直接查缓存:

CREATE TABLE `recommend_cache` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `book_id` bigint NOT NULL, `score` double NOT NULL, `strategy` varchar(16) DEFAULT 'mixed' COMMENT 'mixed/user_cf/content/hot', `create_date` date NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_date` (`user_id`, `create_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日推荐结果缓存';

3.2 推荐服务分层与定时任务

包结构推荐controller → service → recommender → algorithm:算法类只管算,service 管数据组装,controller 管参数校验,recommender层放一个HybridRecommender把三类结果按权重合并。定时任务用 Spring 自带的@Scheduled即可,不必引入 Quartz,毕设项目多引框架只会增加答辩被追问源码的风险。

@Component public class RecommendSchedule { @Autowired private RecommendService recommendService; @Scheduled(cron = "0 30 2 * * ?") public void buildDailyCache() { List<Integer> userIds = recommendService.listAllUserId(); userIds.parallelStream().forEach(userId -> { List<RecommendItem> items = recommendService.mixRecommend(userId, 20); recommendService.saveCache(userId, items, LocalDate.now()); }); } }

cron表达式表示每天凌晨 2 点 30 分执行,避开数据库高峰。parallelStream()要注意并发数,ForkJoinPool默认按 CPU 核数开线程,在线程池里写数据库时如果连接池太小会被卡死,稳妥做法是换newFixedThreadPool(8)。几千个用户一次全量计算没问题,用户数超过十万就要按活跃度切片分批跑,并把日期写进缓存 key。

Spring Boot 配置这里有个高频坑:热词里“springboot版本太高”说的就是启动报Unsupported class file major version。用 Spring Boot 3.x 必须配 JDK 17,而网上大量毕设示例是 Spring Boot 2.7 + JDK 8,导入别人工程时先看pom.xml的 parent 版本,再决定本地 JDK 装哪个,能少折腾半小时。

3.3 推荐接口设计与参数说明

前端需要两个核心接口:推荐列表和行为上报。接口走 REST 风格,统一返回Result<T>结构。

GET /api/recommend/list?userId=1&page=1&pageSize=12

controller 核心逻辑:

@RestController @RequestMapping("/api/recommend") public class RecommendController { @Autowired private RecommendService recommendService; @GetMapping("/list") public Result<PageResult<BookVO>> list(@RequestParam Long userId, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "12") int pageSize) { List<BookVO> books = recommendService.getRecommend(userId, page, pageSize); return Result.ok(PageResult.of(books, totalCount(userId))); } }

pageSize默认 12,对应前端一屏图书墙 12 本;滚动到底部请求下一页就是下拉加载更多。userId从请求参数获取,生产环境应从登录态 Token 解析,毕设为了简化解耦可以做TokenUtil.getUserId(request)透传,但要在答辩时说明这是简化方案。推荐接口必须做空结果兜底:新用户没有任何行为时返回热门图书而非空数组,否则前端白屏。

3.4 性能优化:Redis 缓存与 Key 设计

推荐接口最常见的问题不是算法慢,而是重复计算。每天全量刷一次缓存之后,接口层再叠一层 Redis:

场景Key 形式
用户每日推荐列表recommend:daily:{userId}:{yyyyMMdd}
图书详情book:detail:{bookId}
热门兜底recommend:hot:{date}

用 Redis 缓存时,过期时间设成第二天凌晨 2 点 30 分,与定时任务对齐。

@Cacheable(cacheNames = "recommend:daily", key = "#userId + ':' + T(java.time.LocalDate).now()") public List<BookVO> getRecommendFromCache(Long userId) { return recommendService.recommendFromDb(userId); }

@Cacheable的 key 拼接当天日期,每天的推荐结果天然隔离,不会把前一天的旧列表刷给用户。顺带说一个 Spring Boot 面试题高频考点:@Cacheable的缓存失效发生在方法被调用时,而不是数据过期时,如果白天用户产生了新行为,行为上报接口里必须主动触发@CacheEvict,否则推荐结果要等到第二天才能更新,演示时会显得非常不智能。

4. Vue 前端:从登录到推荐结果的完整链路

4.1 项目初始化、路由与依赖安装

前端推荐 Vue 3 + Vite + Element Plus 起步,命令如下:

npm create vite@latest book-front -- --template vue cd book-front npm install npm install axios vue-router@4 element-plus

装依赖如果出现版本冲突,先看package.jsonelement-plus版本是否被锁死,卸载重装一次通常能解决。Node 版本低于 16 时 Vite 会报Cannot find module 'node:path',npm 缓存损坏则用npm cache clean --force后重装,这两个点对应了热词里反复出现的“vue安装依赖”。项目创建后先建三页:登录页、首页(推荐列表)、图书详情页。

// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', redirect: '/home' }, { path: '/login', component: () => import('@/views/LoginView.vue') }, { path: '/home', component: () => import('@/views/HomeView.vue') }, { path: '/book/:id', component: () => import('@/views/BookDetailView.vue') } ] const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) next('/login') else next() }) export default router

热词“vue路由参数”对应的就是首页跳详情:/book/:id:id是动态路由参数,组件内用route.params.id读取。注意createWebHistory()在生产部署时需要 Nginx 配置try_files $uri /index.html;,否则刷新页面 404,这也是“vue 打包后 布局异常”最常见的根因——不是样式丢了,是路由回退没配。

4.2 Axios 封装与推荐请求三态处理

推荐接口的调用统一封装,挂拦截器处理 Token 和错误码。

// src/api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) request.interceptors.response.use( res => res.data.code === 200 ? res.data.data : Promise.reject(new Error(res.data.msg)), err => { if (err.response && err.response.status === 401) { ElMessage.warning('登录已过期,请重新登录') router.push('/login') } return Promise.reject(err) } ) export default request
// src/api/recommend.js import request from './request' export const getRecommendList = (userId, page, pageSize) => request.get('/recommend/list', { params: { userId, page, pageSize } }) export const reportBehavior = (data) => request.post('/behavior/report', data)

首页组件拿到推荐结果后要处理三个状态:加载中、成功、失败(空列表)。加载状态尤其重要,推荐接口虽然走了缓存,首次进入仍有几百毫秒延迟,没有v-loading用户会以为页面卡死。

4.3 图书卡片组件与推荐理由回显

每本推荐图书用卡片展示封面、书名、作者、推荐理由和评分。推荐理由是加分项,后端在score之外返回reason字段,比如“因为您看过《人类简史》”。

<template> <el-card class="book-card" shadow="hover" @click="goDetail(book.id)"> <img :src="book.coverUrl" :alt="book.title" class="book-cover" /> <div class="book-title">{{ book.title }}</div> <div class="book-author">{{ book.author }}</div> <el-rate :model-value="book.avgRating" disabled /> <div class="book-reason">{{ book.reason }}</div> </el-card> </template> <script setup> import { useRouter } from 'vue-router' const props = defineProps({ book: Object }) const router = useRouter() const goDetail = (id) => router.push(`/book/${id}`) </script>

el-ratedisabled模式只做展示,避免评分组件可点击后误触提交。推荐理由如果后端没返回,直接用v-if隐藏该行,不要显示空字符串占位。

4.4 行为埋点:让推荐数据越用越准

浏览行为上报放在onMounted,收藏和评分在按钮事件里触发。

onMounted(() => { reportBehavior({ userId: userStore.userId, bookId: props.book.id, behaviorType: 1, // 1 浏览 source: 'recommend' // 标记来源,供后续统计推荐位转化率 }).catch(() => {}) // 埋点失败静默,不阻塞页面 })

埋点必须做两件事:失败静默,以及防抖(短时间重复进入同一详情页只上报一次)。用sessionStorage{bookId: lastReportTime}可以实现 30 秒内去重。source: 'recommend'这个字段不能省,后续算推荐位转化率全指望它。

5. 联调与验证:把系统跑起来,并用日志证明推荐有效

5.1 本地启动两端的最短命令

后端先改application.yml里的数据库连接,再启动主类;前端启动后用 Vite 代理解决跨域。

# 后端,端口默认 8080 mvn spring-boot:run # 前端,开发环境下把 /api 代理到后端 npm run dev

Vite 跨域代理配置:

// vite.config.js export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

changeOrigin: true让后端看到的 Host 是 8080 而不是 3000,反向代理鉴权场景经常依赖这个参数。启动顺序先后端再前端,否则前端代理到 8080 时端口未监听,浏览器控制台一直报ERR_CONNECTION_REFUSED

5.2 用日志验证推荐命中率

推荐系统不像 CRUD,肉眼看不出来“推得准不准”,需要日志佐证。我一般在 service 层打两条日志:接口命中的缓存来源(daily cache hit/db rebuild),以及推荐理由的覆盖率。答辩时现场操作“给用户 A 收藏一本《算法导论》,次日刷新推荐列表”,看日志里是否出现UserCF neighbor=5和对应书目。

验证冷启动更直接:新建一个没有行为的userId调推荐接口,断言返回全部来自热门兜底。这条断言可以作为单元测试留在工程里:

@Test void coldStartShouldReturnHotBooks() { List<BookVO> result = recommendService.getRecommend(999999L, 1, 10); assertTrue(result.stream().allMatch(b -> "hot".equals(b.getStrategy()))); }

5.3 三个高频坑与排查思路

第一个坑是前端打包后路由刷新 404。根因是createWebHistory()需要服务端配合,解决方式要么换createWebHashHistory(),要么在 Nginx 配try_files。毕设演示用 hash 模式最省事,但要在文档里注明生产环境建议 history 模式。

第二个坑是 Spring Boot 版本与 MyBatis 依赖冲突。Spring Boot 3.x 要使用mybatis-spring-boot-starter3.0 以上版本,@MapperScan包路径必须正确;启动报Invalid value type for attribute 'factoryBeanObjectType'通常是依赖版本混用,检查pom.xml统一版本号。排查到这一步就够,没有特别必要去啃 MyBatis 源码。

第三个坑是 Vue 页面样式错乱,常见于 Element Plus 全局样式与自定义样式冲突。先打开 DevTools 看 Computed 样式,确认是覆盖失败还是类名没生效;覆盖 Element Plus 组件内部样式时用:deep()

.book-card :deep(.el-card__body) { padding: 12px; }

这三个坑在“springboot面试题”“vue 打包后 布局异常”相关搜索里反复出现,提前写进项目 README,能直观体现排障意识。

6. 加分技巧:给推荐加上可解释性,让答辩现场更可信

可解释推荐是让这套系统脱离“作业感”的关键一步。做法很轻:在混合召回阶段不丢弃来源信息,给每个候选带strategyreason。UserCF 来源的 reason 写成“与你有相似阅读口味的用户也读过《xxx》”,内容过滤来源写成“因为你常看历史类图书”。实现上不动算法核心,只在生成推荐项时追加字段:

public class RecommendItem { private Long bookId; private double score; private String strategy; // user_cf / content / hot private String reason; }

前端已经有book.reason的展示位,后端填上这个字段,肉眼可感知的个性化就出来了。答辩演示时先展示“无推荐理由”的老接口,再切换showReason=true的新接口,对比效果立竿见影。

另一个值得验证的指标是推荐覆盖度,即推荐列表里非热门书的占比。实现是统计 hot 策略条目在总结果中的比例,低于 40% 说明个性化基本没生效。给推荐接口加一个隐藏参数debug=true,返回结果时附带coverage字段:

if (debug) { long hotCount = items.stream().filter(i -> "hot".equals(i.getStrategy())).count(); result.setDebugInfo("coverage=" + (1.0 - hotCount * 1.0 / items.size())); }

这个参数不用写进前端代码,直接浏览器访问GET /api/recommend/list?userId=2&debug=true就能看到。最后留一个能与面试官深聊的扩展点:把 UserCF 的皮尔逊相似度换成基于 ALS 的矩阵分解,冷启动用热门项初始化,升级路径现成。真正让这套源码值钱的不是能用鼠标点通页面,而是你能把“为什么这样选、参数怎么调、失败看什么”讲完整。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询