1. 项目先说清楚:这到底是个什么系统
1.1 模块地图:管理员端加用户端
很多同学拿到一个源码项目,第一件事就是急着启动,结果启动完了开始乱点,过一会儿就不知道自己在干嘛。我习惯拿到项目先看模块结构,先弄清楚功能边界。
这套基于SpringBoot加Vue的个性化图书推荐系统,前后端分离,角色上拆得非常清爽:一端是普通读者,一端是管理员。
读者这边能做的事可以归成四条线:注册登录之后浏览图书、按分类检索图书、查看推荐列表、对图书进行收藏和借阅。推荐列表不是摆设,后面我会细讲它到底是怎么算出来的,这也是这个项目名字里“个性化”三个字的核心所在。
管理员那边就传统多了,负责图书的增删改查、分类管理、用户管理、借阅记录管理、推荐算法的参数维护。说白了,读者端是给用户用的,管理端是给运营方维护数据用的,两边公用一套MySQL数据库,通过后端接口串联起来。
这套结构放在课设或者毕设答辩里,最大的好处是“讲得出东西”:每一个模块都有明确的业务闭环,不是那种为了凑功能硬塞上去的假需求。
1.2 个性化推荐从哪里来:把协同过滤落地
“个性化推荐”听着高大上,落地到课设项目里,最常用的方案并不是深度学习,而是协同过滤。原理通俗讲就是一句话:跟你口味相似的人喜欢什么,我也试着推荐给你。
放到图书场景里来理解。假设读者A喜欢《三体》和《球状闪电》,读者B也喜欢《三体》《球状闪电》,同时还借了《流浪地球》。系统发现A和B的阅读历史高度重合,就会把《流浪地球》推给A,因为“跟你相似的人也在看这本书”。
这个项目里推荐模块的输入主要来自三类数据:借阅记录、收藏记录、评分记录。如果用户还没产生足够的行为数据,系统会退化为热门推荐,也就是按借阅次数和评分排序,把最受欢迎的图书推出来。这种“冷启动降级”的处理方式,是实际工程里非常常见的套路,直接体现了设计者有没有工程经验。
2. 技术栈选型解析:为什么这套组合是“标准答案”
2.1 后端SpringBoot:零配置带来的效率提升
后端用SpringBoot,早几年可能还有人纠结要不要用SSH(Struts加Spring加Hibernate),到现在这个时间点,基本不用犹豫了。SpringBoot最大的价值在于“约定大于配置”——你不用再像传统SSH那样维护一堆XML配置文件,启动一个Web项目只需要一个加了@SpringBootApplication注解的入口类。
我做过的几个课设项目对比下来,SpringBoot对新手最友好的地方其实有两个。
第一个是内嵌Tomcat。以前部署一个项目要单独装Tomcat、配置server.xml、把war包丢进webapps,现在直接mvn spring-boot:run或者java -jar就能跑起来,少了一层环境认知负担。
第二个是起步依赖(Starter)。比如你想用Web能力,就引入spring-boot-starter-web;想操作数据库,就引入mybatis-spring-boot-starter。依赖关系由SpringBoot统一管理版本,基本告别了“依赖冲突地狱”。
2.2 前端Vue加Element UI:前后端分离的工程化体验
前端选了Vue,具体版本需要留意一下。如果项目用的是Vue 2,那配套的是Element UI;如果是Vue 3,那对应的是Element Plus。这两套组件库API有差异,混着看容易越看越乱。
Vue这个框架的上手曲线是平缓的:模板语法类似HTML,数据绑定用v-model,列表渲染用v-for,条件渲染用v-if,有HTML基础的同学两天就能写出像样的页面。配合Vue Router做路由跳转,配合Axios做接口请求,就很自然的把前端工程化这件事做了出来。
我在开发这套系统的时候,前端目录结构是这么拆的:
src/ ├── api/ // 所有后端接口请求封装 │ ├── book.js │ ├── user.js │ └── recommend.js ├── router/ // 前端路由表 ├── views/ // 页面组件 │ ├── Login.vue │ ├── Home.vue │ ├── BookList.vue │ └── Recommend.vue ├── components/ // 复用的公共组件 └── utils/ // 工具类,比如axios封装把api和views分开,是我给课设项目定的一个硬性规范。很多同学喜欢在页面里直接写axios.get,页面一多,改一个接口地址要全局搜,非常痛苦。统一封装之后,后端接口变了只改一个文件,省心得多。
2.3 MyBatis加MySQL:SQL可控、上手成本低
持久层选了MyBatis而不是JPA,有一个非常现实的原因:课设答辩的时候老师大概率会问你SQL,MyBatis把SQL写在自己手里,你能清楚讲出每一条查询是怎么写的,JPA那种自动生成的SQL反而容易让自己说不清。
MySQL就更不用说了,开源、免费、跨平台,大学课程里教的基本就是它。搭配Navicat或者DataGrip做可视化操作,建库建表导数据都很快。
这三样组合在一起,加上Maven做依赖管理,整个技术栈全部是业界主流。换句话说,哪怕你不是为了做课设,是用这套东西练手找工作,简历上写“熟悉SpringBoot、Vue、MyBatis、MySQL全家桶开发”也是有分量的。
3. 核心实现拆解:推荐算法、权限、分页与缓存
3.1 推荐算法的具体落地:三步走实现UserCF
这个项目的推荐模块,我实现的是基于用户的协同过滤(UserCF)。虽然叫“算法”,但落到代码上就是一个三步走的流程。
第一步:构建“用户-图书”行为矩阵。数据源是借阅表、收藏表和评分表,为了简单直观,我把它们统一折算成用户对图书的兴趣度。
-- 构建用户行为表 user_book_behavior SELECT user_id, book_id, MAX(score) AS score FROM ( SELECT user_id, book_id, 5 AS score FROM borrow_record UNION ALL SELECT user_id, book_id, 3 AS score FROM favorite UNION ALL SELECT user_id, book_id, rating AS score FROM rating ) t GROUP BY user_id, book_id这里我用的是SQL层直接聚合,比在Java内存里遍历要清爽很多。分数映射的逻辑是:借阅记5分、收藏记3分、评分按1到5原值计算。
第二步:计算用户之间的相似度。经典方式是余弦相似度或者皮尔逊相关系数,在课设场景我选余弦相似度,实现简单,讲起来也直白。
public double cosineSimilarity(Map<Integer, Double> user1, Map<Integer, Double> user2) { Set<Integer> commonKeys = new HashSet<>(user1.keySet()); commonKeys.retainAll(user2.keySet()); if (commonKeys.isEmpty()) return 0.0; double dotProduct = 0.0; double norm1 = 0.0; double norm2 = 0.0; for (Integer key : user1.keySet()) { norm1 += Math.pow(user1.get(key), 2); } for (Integer key : user2.keySet()) { norm2 += Math.pow(user2.get(key), 2); } for (Integer key : commonKeys) { dotProduct += user1.get(key) * user2.get(key); } return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); }这里有个细节值得说:相似度计算只遍历了两个用户共同看过的图书,没有共同看过的用户,相似度直接是0,可以提前跳过。这个剪枝优化在数据量小的时候感觉不出来,但数据量一旦上千,差距非常明显,能把几秒的计算压缩到几百毫秒。
第三步:基于相似用户生成推荐。拿到当前用户的TopN相似用户后,把这些用户的行为图书汇总,剔除当前用户已经读过的书,按相似度加权后的分数排序,取前N本。
推荐结果最终落到一张推荐表里,前端查询的时候直接读这张表。为什么要落表而不是实时算?因为实时计算的话每次用户刷新推荐页都要触发全量计算,性能扛不住;而把结果定时刷新到推荐表,比如每天凌晨跑一次,或者每次用户产生行为后增量更新,体验要好得多。
3.2 登录与权限:JWT怎么集成不踩坑
登录模块我用的JWT加拦截器方案。流程是:用户输入账号密码,后端校验通过之后生成一个带有效期的token,返回给前端;前端把token存在localStorage里,每次请求在请求头里带上Authorization: Bearer xxx;后端拦截器统一校验token,没带或者过期就返回401。
项目里拦截器配置要注意放行路径。登录接口、注册接口、静态资源一般不需要鉴权,图书列表这种基础数据接口通常也不用,但借阅、收藏、后台管理等接口必须校验。我见过不少同学把拦截器配置成了拦截所有路径,结果静态资源全成404,排查一个小时才发现是拦截器把所有请求都吃了。
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/login", "/api/register", "/api/books/**"); }还有一个必须要提醒的:JWT密钥不要写在代码里写死。哪怕课设很简单,也建议放在application.yml里配置,至少做到配置与代码分离。别问我为什么,答辩老师看到硬编码密钥是有可能追问的。
3.3 MyBatis分页与缓存:这些用法直接写到简历里
3.3.1 PageHelper分页插件
图书列表如果没有分页,数据一多页面就卡。MyBatis的分页方案有很多种,最省心的是PageHelper插件。
用法就三步:引入依赖、配置拦截器、在Mapper查询前调用PageHelper.startPage。
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>// Service层 public PageInfo<Book> getBookList(int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); List<Book> books = bookMapper.selectAll(); return new PageInfo<>(books); }PageHelper.startPage之后紧接着的那一条SQL查询会被自动加上limit,注意中间不能再有别的查询操作,否则MyBatis的拦截器会傻傻分不清到底给哪条SQL做分页。
这里还有个坑在于:分页参数如果直接从前端传过来,要加参数校验。pageNum最小是1,pageSize最大建议100,不校验的话有人传个9999999进来,能一次把数据库打穿。
3.3.2 MyBatis缓存:一级缓存和二级缓存
MyBatis的一级缓存是默认开启的,作用范围是同一个SqlSession。这个在单体应用里感知不强,因为Spring整合之后每次执行一个方法可能都新建了SqlSession,缓存根本用不上。
二级缓存才是真正跨SqlSession的。在Mapper XML里加一行<cache/>就能开启,查询结果会序列化后存入缓存,下次相同查询直接命中。
但二级缓存我建议慎开,尤其在课设项目里。原因是它默认对图书数据没问题,一旦涉及关联查询和多表更新,缓存失效的清理逻辑很麻烦。如果一张图书表的数据更新了,但你查询的是“图书加分类”的联表结果,MyBatis默认只认识自己的命名空间,其他命名空间更新了数据它不知道,查询出来的还是旧缓存。我当初踩过这个坑后,直接把二级缓存关了,图书这类低频变更数据用Redis做缓存才是正解。
3.4 Vue端路由与请求封装:前端也不能写得太随意
前端的核心就是路由和请求。
路由需要在vue-router里配置,并且要用动态路由做权限控制。管理员登录后能看到“后台管理”入口,普通用户看不到,这个不需要复杂的权限框架,前端路由加一个meta.roles字段,路由守卫里判断一下就行。
// 路由守卫 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else { next(); } });请求封装用Axios创建实例,设置baseURL和超时时间,再添加请求拦截器和响应拦截器。响应拦截器里最重要的是统一处理401:后端返回未认证,直接跳回登录页,把过期token清掉。
// axios 请求封装 service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );这个写法能避免每个页面都去写一遍401处理逻辑。
4. 从源码到跑通:全套实操记录
4.1 环境准备:版本搭配对照表
拿到源码后别急着启动,先把环境对齐。我整理了一份我实测过的版本搭配:
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| JDK | 1.8 或 11 | 大多数SpringBoot 2.x项目JDK8即可 |
| Maven | 3.6.3及以上 | 低于3.5容易下载依赖失败 |
| Node.js | 14.21.3(Vue2项目);18.x(Vue3项目) | 建议用nvm切换 |
| npm | 6.x或8.x | 跟随Node版本 |
| MySQL | 5.7或8.0 | 两者SQL语法略有差异 |
| SpringBoot | 2.x | 具体看项目pom文件 |
以上这套版本组合我跑通的不止一个项目,稳定性很好。特别提醒:不要一上来就装JDK17加SpringBoot3.x,那是另外一套玩法,很多课设项目并不兼容。
JDK安装之后记得配环境变量,JAVA_HOME和PATH两个配置缺一不可。在命令行里执行java -version能正常输出版本,说明配置成功。Node安装后执行node -v和npm -v验证。Maven安装后执行mvn -v验证。MySQL安装后能通过命令行或者Navicat连上就行。
4.2 数据库初始化:建库、导表、灌数据
数据库这块是整个项目启动成败的关键,也是最容易出问题的地方。源码包里通常会有sql文件夹,里面有项目对应的建表脚本和初始数据。
第一步是登录MySQL创建数据库。注意字符集一定要指定utf8mb4,不然以后存中文、存表情符号会乱码。
CREATE DATABASE IF NOT EXISTS book_recommend DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后用mysql命令行导入SQL文件:
mysql -u root -p book_recommend < book_recommend.sql或者直接在Navicat里右键运行SQL文件,都行。导入完成后,重点看一下user表、book表、borrow_record表有没有数据。如果借阅记录是空的,推荐模块暂时不会有输出,属正常现象,后面测试的时候可以手动造几条记录。
数据库里容易忽略的是初始管理员的账号密码,一般SQL脚本里会写死,比如admin / 123456。如果文档里没写,直接查库:
SELECT id, username, password, role FROM user;就能看到。
4.3 后端启动:四个步骤
后端启动路径比较标准化,我按顺序来:
第一步,用IDEA打开后端项目目录,等待Maven下载依赖。这一过程快慢取决于网络,国内建议配阿里云镜像。
第二步,检查application.yml里的数据库配置。重点核对这几个值:
spring: datasource: url: jdbc:mysql://localhost:3306/book_recommend?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: 123456MySQL 8.0以下版本不需要serverTimezone参数,8.0以上必须加,否则会报时区错误。
第三步,直接运行主类里的main方法,或者在根目录执行:
mvn spring-boot:run看到Started Application in xxx seconds字样,后端就算启动成功了。默认端口一般配置在server.port,常见的是8080,如果被占用,改掉即可。
第四步,验证接口是否可用。浏览器访问http://localhost:8080/api/books/list?pageNum=1&pageSize=10,能返回JSON数据,说明后端和数据库链路正常。
4.4 前端启动:npm安装依赖
前端启动别直接双击index.html,Vue项目是工程化的,必须走构建流程。
进入前端目录,执行:
npm install这个过程同样依赖网络,如果卡住不动,很可能是npm源的问题。换成淘宝镜像能解决90%的安装问题:
npm config set registry https://registry.npmmirror.com安装完成后再启动开发服务:
npm run serve默认启动在http://localhost:8081。端口跟前端环境配置文件里的proxy目标要对上,一般前端项目里的.env.development文件会写VUE_APP_BASE_API这个变量,如果你是后端8080,这里保持默认即可。
浏览器打开前端地址,能跳到登录页并成功登录,前后端联调就跑通了。
4.5 联调自测:从注册到推荐全链路
整个系统跑起来之后,我建议自测这么一轮流程:
- 注册一个新用户,验证用户表新增成功。
- 用新身份浏览图书列表,翻页正常,收藏一本感兴趣的图书。
- 再注册第二个用户,也收藏相同的书,并且借阅其中一两本。
- 回到第一个用户的推荐页,看是否有来自相似用户的图书推荐。
- 用管理员账号登录后台,新增一本图书、编辑一条借阅记录,验证CRUD正常。
- 退出管理员账号,确认普通用户无法访问后台接口,直接拼接URL访问也被拦截。
这套自测走完,系统的主要功能就都覆盖到了,答辩的时候也能理直气壮说“我完整实测过”。
5. 踩坑实录与避坑指南
5.1 问题表格速查
我把实际开发和跑通过程中最常遇到的问题整理成一张速查表:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前端请求后端接口报404 | 前后端端口不一致或后端未启动 | 确认server.port和前端baseURL匹配 |
| 登录接口报401后跳转登录页循环 | 请求头没带token或token过期 | 检查Axios请求拦截器是否携带Authorization |
| 数据库连接报“Public Key Retrieval is not allowed” | MySQL 8.0的安全限制 | URL加allowPublicKeyRetrieval=true |
| 中文乱码 | 数据库字符集不是utf8mb4 | 建库时指定utf8mb4,连接串加characterEncoding=utf8 |
| 分页不生效,返回全部数据 | PageHelper被误用在查询之前还有别的SQL | 确保startPage紧跟目标SQL |
前端npm run serve失败 | Node版本过高或者依赖缺失 | 删除node_modules重新npm install |
| 推荐列表始终为空 | 用户行为数据太少 | 手工插入借阅、收藏数据后再测试 |
| 图片不显示 | 上传文件路径问题 | 检查上传目录和静态资源映射配置 |
5.2 JWT过期时间这个细节
很多人测试的时候会遇到一个特别尴尬的场景:用着用着突然被踢出登录了,重新登录一趟又能继续用。并不是代码写错了,而是JWT过期时间设置得太短。
有的源码把过期时间设成30分钟甚至15分钟,对课设演示来说确实太短了。我建议把它改成24小时,也就是毫秒值设为86400000,这样演示全程不会中途跳登录。正式商业项目当然要结合安全策略设置短一点,但课设场景以演示顺畅为优先。
5.3 MyBatis需要留心的两个Mapper细节
第一个是Mapper接口和XML文件的映射。接口方法名和XML里的id必须完全一致,否则运行时报Invalid bound statement (not found)。这个问题排查要点是看编译后target目录下有没有对应的XML,有同学把XML放在src目录下的java包里,没在pom里配置资源路径,结果编译时直接被跳过,怎么调都报错。
<!-- pom.xml里加上资源配置 --> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources>第二个是<![CDATA[]]>符号。MyBatis的XML里写<、>、&会被当成XML标签解析,时间范围查询里的<就会报错,解决办法是把SQL包在CDATA段里。
<select id="selectByTime" resultType="Book"> <![CDATA[ SELECT * FROM book WHERE create_time < #{endTime} ]]> </select>5.4 推荐系统没有推荐数据的排查思路
这是这个项目里最值得唠叨的一个点。很多同学把系统跑起来之后,发现推荐页空空如也,第一反应是算法写错了。按照我的经验,90%的情况不是算法问题,而是数据问题。
具体排查思路按顺序走:
先看行为表数据量。如果用户表里只有一两个用户,图书表里只有几条记录,任何推荐算法都变不出花样。要手工在数据库里造数据,至少让两个用户有交集,比如用户A收藏了《三体》、借阅了《流浪地球》,用户B也收藏了《三体》、借阅了《球状闪电》,这样A和B才有相似度可算。
再看当前用户是否已经有推荐结果。推荐表在用户首次登录时可能不会立即生成,需要触发一次计算任务。很多源码里的推荐任务是定时跑的,测试时手动执行一次计算接口,或者重启服务时执行初始化,才能看到效果。
最后才怀疑算法本身。检查相似度函数入参的Map构建是否为空,检查推荐结果去重时是不是把当前用户读过的书全剔除了(数据少的时候很可能全被剔光)。
5.5 数据库升级到MySQL 8之后的一些坑
如果你的本机是MySQL 8.0,而源码里的驱动依赖还是5.1.47之类的老版本,连接时大概率报Access denied for user或者Communications link failure。把驱动版本升级到8.0.x,同时把连接串换成com.mysql.cj.jdbc.Driver就行。
MySQL 8比MySQL 5.7多了个caching_sha2_password认证插件,老版本的客户端驱动不兼容这个插件,驱动升级后问题自然就消失了。另外MySQL 8默认的only_full_group_by模式比较严格,如果你写的分组查询的select字段没有全部出现在group by里,会直接报SQL错误。实在不想改SQL的话,可以修改SQL模式:
SET GLOBAL sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO';5.6 前后端联调的跨域问题
本地开发最典型的报错是浏览器控制台出现Access to XMLHttpRequest at ... from origin ... has been blocked by CORS policy。
这个问题的思路是:后端没做跨域配置,或者做了但没生效。SpringBoot里最直接的解法是写一个配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:8081") .allowedMethods("*") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }更稳妥的方式是前端在vue.config.js里配置代理,把/api的请求代理到后端8080,这样浏览器看到的始终是同源请求,不会有跨域问题。
// vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };两种方式我用得比较多的是代理方案,因为生产环境部署时一样要配反向代理,提前用代理模式开发能让前后端环境更加接近线上形态。
6. 我对这套源码的一些看法
最后说点实际开发的体会。
这套项目的技术栈选择很稳,SpringBoot、Vue、MyBatis、MySQL都是当前最主流的技术,不是过气框架。哪怕将来你要在这个项目基础上再做扩展,比如接入Redis缓存、换成RabbitMQ做异步消息、加一个Spark离线计算推荐任务,技术路线都是平滑的。
对准备拿它做课设的同学,我的建议是:不要只把系统跑通就完事,至少把推荐模块的代码完整读一遍,把协同过滤的三步讲清楚——如何构建用户行为矩阵、如何计算相似度、如何生成推荐结果。答辩时能把这个链路讲明白,比什么花哨的功能都加分。
对想把它当“毕设起点”的同学,也有一条明确的升级路径:可以给推荐模块加入实时计算能力,用户每次借阅或收藏后立即更新相似用户和推荐列表;或者引入Redis缓存热门图书和推荐结果;再或者做一个GraphQL接口层替代现在的Restful接口。每一步改造都让项目的技术含量上一个台阶。
这套代码我前后部署过好几次,每次都能顺利跑通。只要严格按照顺序来——先建库导数据,再启动后端验证接口,最后启动前端联调——基本半小时内就能看到完整页面。遇到问题先看环境版本,再看日志,最后看代码逻辑,大部分坑都能自己解决。
最后再送给读者一个实用技巧:拿到任何源码项目,第一件事不是看代码,而是看项目根目录的README和SQL文件夹。README里往往写着启动步骤、默认账号密码、端口号,SQL文件夹里的表结构能让你对整个系统的数据结构心里有数。把这两个看完,你对这个项目的理解就已经超过了60%的人。