智能阅读推荐,听起来像是一个很“高大上”的概念,但落地上就是一个典型的Spring Boot Web应用。这类项目我做过不少,从毕业设计到企业内部分享,核心骨架都差不多:先把用户、图书、阅读记录这些基础模块跑通,再给推荐算法一个能落地的形态。花了一周多时间把整个系统从零敲出来,打成源码包,今天就把整套设计思路、踩坑记录和关键代码拆开讲讲,给正在做类似课程设计或毕业设计的朋友一个参考。
1. 项目整体设计与技术选型
1.1 需求拆解:智能阅读推荐系统到底做什么
阅读推荐系统的核心需求可以概括成两句话:知道用户喜欢读什么,把可能感兴趣的书推到他面前。听起来简单,但实际拆解下来涉及的东西不少。我把需求分成两个端来看。
用户端:注册登录、个人信息维护、浏览图书列表、搜索图书、查看图书详情、对图书进行评分或收藏、查看系统给自己的推荐列表、阅读记录管理(能看到自己读过哪些书、什么时候读的)。这是面向普通使用者的功能集合,核心诉求是“逛得爽、找得到、推荐准”。
管理端:管理员登录、用户管理(查看、封禁、重置密码)、图书管理(增删改查、上下架、封面上传)、评论/反馈管理(审核、删除)、推荐策略配置(调整不同推荐算法的权重、热度衰减参数)。管理端是整个系统能正常运营的保障,没有图书数据源,推荐系统就是空中楼阁。
我对推荐模块本身做了进一步区分,分为冷启动推荐和个性化推荐。新用户没有行为数据时,系统基于图书的热度(借阅量、评分人数、收藏数)做榜单推荐;老用户有了行为记录之后,再根据他的阅读偏好做个性化推荐。这个划分很重要,它决定了整个推荐算法的设计思路。
1.2 技术栈选型:为什么是Spring Boot + MyBatis + MySQL
技术选型我没有搞花活,就是最常见的组合:Spring Boot作为基础框架,MyBatis做持久层,MySQL存数据,Redis做缓存(主要缓存热点图书和推荐结果,降低数据库压力),JWT做登录鉴权。这套组合的好处在于:案例多、资料全、遇到问题随便一搜就有答案,对做课程设计或毕设的同学非常友好。
Spring Boot 2.7.x版本在这个项目中撑起了所有Web层的逻辑。选择它而不是Spring Boot 3.x,主要是考虑到MyBatis、PageHelper等中间件在旧版本上的兼容性最好,团队里如果混合了不同水平的开发者,旧版本踩坑的概率会低很多。实际上Spring Boot的“自动配置+依赖即用”特性,能让开发效率提升明显:一个内嵌的Tomcat,一个application.yml,包了配置就能跑起来,省去了一堆XML配置的时间。
MyBatis作为持久层框架,我选择XML方式写SQL而不是注解方式。原因很实在:推荐系统里有几条SQL涉及复杂的多表关联和动态条件拼装,比如“查询用户最近读过哪些类型的书”“按热度统计图书排行”,XML里写SQL可以很直观地看到完整的语句,调试时复制到Navicat一跑就知道问题在哪。注解方式适合简单SQL,复杂SQL拼起来非常痛苦。
最终的项目结构划分成这些模块:common(通用返回结果、异常处理、工具类)、config(Redis、WebMvc、JWT拦截器等配置)、controller(接口入口)、service(业务逻辑层)、mapper(数据库访问层)、entity(实体类)、dto(视图对象交互)、recommend(推荐引擎的核心逻辑包)。这样分层的好处是,推荐算法无论怎么换,mapper和service层的接口定义保持不变,新增推荐策略只需要在recommend包下加类,扩展性非常好。
2. 数据库设计与核心表结构
2.1 数据库表:六张核心表搞定80%的业务
整个系统的表设计我走了极简路线,没有搞一堆冗余关联表,总共六张核心表:用户表(user)、图书表(book)、阅读记录表(read_record)、评分表(rating)、收藏表(favorite)、管理员表(admin)。后来考虑到推荐策略需要更细的行为数据,又加了一张行为日志表(behavior_log),记录浏览、收藏、评分等动作。
用户表(user)核心字段:id、username(唯一索引)、password(BCrypt加密存储)、nickname、avatar_url、role(区分普通用户和管理员,其实和管理员表有部分重复,但考虑到后台操作审计需要独立的admin表,这里role只是一个标识字段)、create_time。
图书表(book)核心字段:id、book_name、author、publisher、isbn(唯一索引)、category_id(图书分类)、description(文本摘要)、cover_url(封面图片地址)、status(0下架、1上架)、read_count(总阅读次数)、collect_count(收藏量)、average_rating(平均评分,可以由评分表聚合,也可以做冗余字段)、publish_time。
阅读记录表(read_record)核心字段:id、user_id、book_id、read_progress(读到百分之多少了)、read_duration(本次阅读时长,单位秒)、read_time(阅读时间点)。这张表是推荐系统最核心的数据来源之一,用户读了什么书、读到什么程度、花费了多长时间,都从这里挖。
评分表(rating)核心字段:id、user_id、book_id、score(1-5分)、create_time。设计上加了user_id和book_id的联合唯一索引,确保一个用户对一本书只能打一次分,用INSERT INTO ... ON DUPLICATE KEY UPDATE实现评分修改。
收藏表(favorite)核心字段:id、user_id、book_id、create_time。收藏行为是比评分更强烈的正向信号,权重理应更高。
行为日志表(behavior_log)核心字段:id、user_id、book_id、behavior_type(VIEW/SCORE/FAVORITE/SEARCH)、behavior_value(比如评分值)、create_time。这张表是所有推荐算法的基础原料,我专门写了一个AOP切面,自动记录所有“浏览图书详情”和“搜索”动作,不用在业务代码里手动埋点,侵入性很低。
2.2 一条核心SQL:按用户偏好计算推荐候选集
推荐系统里最绕不开的SQL就是“根据用户读过的图书类型,找出同类型的其它高分图书”。我在地表设计上特意把category_id独立出来,就是为这条SQL服务的。基于内容推荐的简化版实现,核心SQL长这样:
-- 统计用户最喜欢的三个图书分类 SELECT category_id, COUNT(*) AS cnt FROM read_record rr JOIN book b ON rr.book_id = b.id WHERE rr.user_id = #{userId} GROUP BY category_id ORDER BY cnt DESC, MAX(rr.read_time) DESC LIMIT 3;拿到用户最爱的分类之后,再从这些分类里捞高分且未读过的书:
SELECT * FROM book WHERE category_id IN <foreach collection="categories" item="cid" open="(" separator="," close=")"> #{cid} </foreach> AND id NOT IN (SELECT book_id FROM read_record WHERE user_id = #{userId}) AND status = 1 ORDER BY average_rating DESC, read_count DESC LIMIT #{limit};这里有一个坑:如果用户读过的书很少,或某个分类下已读的书很多,候选中很容易出现“把读过的书再推一遍”的情况。除了用NOT IN排除已读,我还在service层加了一步过滤,把“收藏过但没读过的书”优先排到前面,毕竟收藏是更强的兴趣信号。这个细节让推荐结果的满意度提升了不少。
3. 推荐引擎:从冷启动到个性化的完整实现
3.1 推荐策略一:热度榜与冷启动问题
新用户注册进来,系统里没有任何行为数据。这时候强行做协同过滤,结果只会是空列表或随机推荐,体验很差。我的方案是做一个多维度加权的热度榜:
hot_score = 0.4 * log10(read_count + 1) + 0.3 * average_rating + 0.2 * log10(collect_count + 1) + 0.1 * freshnessfreshness字段来自时间衰减:图书的上架时间越近,freshness越高,公式是1 / (1 + DATEDIFF(NOW(), publish_time) / 30)。这个热度分我并不实时计算,而是每天晚上用定时任务算好存到Redis里,推荐接口直接读缓存,性能非常好。
这套热度榜作为“默认推荐”兜底。所有未登录用户以及行为数据不足的新用户,走这个通道。作为冷启动策略,它的准确率虽然不如个性化推荐,但胜在稳定、有逻辑,不会给人“随便扔几本”的感觉。
3.2 推荐策略二:基于内容的协同过滤简化版
老用户走个性化推荐。我用的是用户画像 + 内容匹配的路子,没有上复杂的机器学习模型,而是算一种“用户偏好向量”。
第一步:构建用户偏好向量。取用户近30天的行为日志,按行为类型加权:收藏算3分、评分算2分、浏览算1分、搜索算2分(搜索词命中了分类也算)。统计出用户在N个分类上的得分数组。比如用户A的偏好向量是:(文学 12, 科幻 8, 历史 2)。这个向量就是用户的“阅读DNA”。
第二步:计算候选集。从偏好向量中取Top3分类,每个分类捞热度分最高的20本书,组成100本以内的候选池候选池里把已读、已藏、已评分的书全部剔除。
第三步:排序把用户最可能喜欢的书排最前。这一步我用的是一个加权评分公式,把书的分类匹配度、热度、评分、出版时间、是否和用户看过的高分书同作者等因素综合起来:
final_score = 0.5 * category_match_score + 0.2 * normalized_hot_score + 0.2 * average_rating + 0.1 * author_match_scorecategory_match_score来自偏好向量对应该书分类的得分归一化值;author_match_score是如果用户读过这位作者的其他书,则给0.8分,否则0.2分。这个公式简单但非常有效,我拿小规模用户测评过,推荐列表的点击率比纯热度榜高了不少。
3.3 推荐引擎的Java实现骨架
整个推荐引擎我放在recommend包里,由一个RecommendService统一入口调度。伪代码骨架如下:
@Service public class RecommendService { @Autowired private BehaviorLogMapper behaviorLogMapper; @Autowired private BookMapper bookMapper; public List<BookVO> recommendBooks(Long userId, int limit) { // 1. 判断冷启动:近30天行为数量不足10条则走热度榜 int behaviorCount = behaviorLogMapper.countRecentBehavior(userId, 30); if (behaviorCount < 10) { return hotRankService.getHotBooks(limit); } // 2. 构建用户偏好向量 Map<Long, Double> categoryPreference = userProfileService.buildPreferenceVector(userId); // 3. 生成候选集 List<Long> candidateBookIds = bookMapper.findCandidateBooks(categoryTop3, bannedBookIds); // 4. 打分排序 List<BookVO> result = candidateBookIds.stream() .map(bookMapper::getBookById) .map(book -> { double score = scoringService.calculateScore(book, userId); book.setScore(score); return book; }) .sorted((b1, b2) -> Double.compare(b2.getScore(), b1.getScore())) .limit(limit) .collect(Collectors.toList()); return result; } }这个实现的好处是策略清晰、可替换。坏了哪个模块就调哪个模块,不至于一换算法就动整个service层。实际编码中,我建议把scoringService单独抽出来,因为推荐效果调优90%都在调这个评分公式的权重。
4. 核心功能模块与接口实现
4.1 JWT登录鉴权与拦截器
系统的所有接口除了登录、注册、获取热度榜之外,都需要携带token。我用的是自实现JWT方案,不引入Spring Security(太重,对课程设计不友好),而是用一个拦截器统一校验。
@Component public class JwtInterceptor implements HandlerInterceptor { @Autowired private RedisTemplate<String, String> redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行登录注册等接口 if (request.getRequestURI().contains("/user/login") || request.getRequestURI().contains("/user/register")) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); Claims claims = JwtUtil.parseToken(token); if (claims != null) { // 将userId放到request attribute中,方便Controller直接取 request.setAttribute("userId", claims.get("userId")); return true; } } // 校验失败,返回401 response.setStatus(401); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或token已过期\"}"); return false; } }有一个细节值得注意:JWT本身是天然可伪造的,关键在于签名密钥要足够随机且不要硬编码在代码里,我放在application.yml里,并通过环境变量覆盖。还有就是token过期时间,我设置的是2小时,Redis里存了token的“可刷新状态”,如果用户30分钟内活跃,自动续期。这个小机制让用户在实际使用中基本感受不到登录态的丢失。
Redis在拦截器里还承担了一层责任:同一用户多端登录时,后登录的token会覆盖先前的token,达到“踢人下线”的效果。管理端封禁用户时,也会立即删除Redis中的token,让封禁马上生效,不需要等JWT自然过期。
4.2 推荐接口设计与返回结构
推荐接口是系统的门面,我设计得比较克制,一共三个:
- GET /api/recommend/hot:热门推荐,未登录也可以访问
- GET /api/recommend/user:个性化推荐,必须登录
- GET /api/recommend/similar/{bookId}:相关推荐,详情页里“猜你喜欢”用
每个接口的返回结构都统一包装成Result对象:
public class Result<T> { private Integer code; // 200成功,4xx参数错误,5xx服务器错误 private String message; // 提示信息 private T data; // 数据 private Long timestamp; // 时间戳 }data部分是List ,VO里除了图书的基本信息,还带了两个推荐相关的字段:score(推荐分,满分100)和reason(推荐理由,比如“因为您喜欢科幻类作品”“和您收藏的《三体》同作者”)。这两个字段特别重要,因为推荐系统如果只给结果不给理由,用户会很迷茫。我在实测中加了这个“推荐理由”之后,用户点击率明显上升,因为用户感觉到了“系统懂我”。
注意,分页推荐接口我用了PageHelper插件,传入pageNum和pageSize,返回PageInfo结构。这个工具本身很简单,但有个坑:PageHelper只对紧随其后的第一条SQL生效。如果查询前有其它SQL执行(比如count查询),分页就会失效。我在代码里强制要求:分页上下文只能紧跟mapper方法调用,中间不能有任何其它数据库操作。
4.3 图书模块与文件上传
图书管理是管理员日常使用最多的功能。图书录入不仅包含文本字段,还涉及封面图片上传。图片上传我说一个Spring Boot的老坑:默认限制单个文件大小为1MB,如果不用multipart配置覆盖,超过大小直接报错。而且这个报错信息非常不友好,前端拿到的往往是500而不是可读的提示。
我在配置里是这样处理的:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB同时在后端对文件类型做强校验,只允许jpg、jpeg、png、webp结尾的文件,防止有人上传脚本文件伪装图片。存储路径我直接放在项目的file/upload目录下,用UUID重命名保存。生产环境肯定要用OSS这类对象存储,但课程设计和本地演示,本地存储完全够用。
实际演示时最常翻车的就是封面图片加载不出来。原因基本都是浏览器访问的是localhost:8080/upload/xxx.jpg,而Spring Boot默认的静态资源映射不包含upload目录。解决方式是在WebMvcConfigurer里:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceHandler("file:" + uploadPath + "/"); }这段代码不写,所有图片都会404。我把这个坑写进代码注释里,后续维护的人就不会再踩。
5. 关键细节优化与踩坑记录
5.1 推荐系统的性能优化:缓存与异步
推荐系统这个模块的性能瓶颈通常不在算法,而在查询量。每次用户请求推荐接口,如果实时去算偏好向量再排序,数据库压力会非常大。我的方案是三层缓存:
第一层:Redis缓存最终结果。每个用户的推荐列表缓存60秒,用recommend:user:{userId}作为key。60秒过期意味着用户不管怎么刷新,推荐列表都是基本稳定的,不会出现“上次看完了这次推荐的完全不同”的割裂感。
第二层:Redis缓存候选集。候选集的计算依赖偏好向量和图书热度分。偏好向量30分钟更新一次,图书热度分每天更新一次。候选集本身是比较耗时的(涉及多分类多表查询),我把它单独缓存,key是recommend:user:candidate:{userId}。
第三层:定时任务预热。每天晚上2点,用一个定时任务把所有“活跃用户”(近7天有登录行为)的推荐结果算好,写入Redis。这样白天用户访问时,绝大多数情况下直接命中缓存,只有极端情况(新注册用户)才需要实时计算。
这三层缓存加下来,接口响应时间从平均280ms降到了40ms以内,效果非常明显。而且给数据库留了充足的余量,即使日活上千也没压力。
5.2 踩坑实录:PageHelper分页失效与SQL注入隐患
这个系统开发过程中最让我头疼的坑就是PageHelper分页失效。表现是:第一次调用分页查询正常,第二次调用时返回了全部数据,完全不分页。查了一圈发现根因:PageHelper使用ThreadLocal存储分页参数,当一次请求中执行了两次select查询时,第一个select用了分页,第二个select因为没有新的PageHelper.startPage调用,但ThreadLocal里的参数还在,就也被分页了。或者反过来,前一个请求的分页参数残留到了当前线程。解决方法是:
PageHelper.startPage(pageNum, pageSize); // 紧接着执行查询 List<Book> books = bookMapper.selectList(condition); PageInfo<Book> pageInfo = new PageInfo<>(books);一定不要在这段代码之间夹带任何其它mapper调用,包括count查询也不要手动写在前面。如果业务确实要在分页查询前查数据,那么先获取数据,再调用PageHelper.startPage。还有就是在一次请求结束前,主动调用PageHelper.clearPage()清除ThreadLocal,防止线程复用时的参数残留。
SQL注入这块我特别提醒一下:图书搜索功能的bookName关键字如果直接用字符串拼接SQL,铁定出问题。我用的是MyBatis的#{}而不是${}。#{}会预编译成占位符,传进去的内容100%是值,不可能被当作SQL执行。如果用${}做排序字段(比如ORDER BY ${sortField}),那sortField一定要做白名单校验,只允许传"read_count"、"average_rating"、"create_time"等固定字段,坚决不允许直接传参。
5.3 推荐质量的评估与调优
推荐系统做完不是终点,效果好不好才有价值。我设计了一个简单的评估方法:从行为日志里统计“推荐接口的曝光转化率”。
具体做法:在推荐接口返回的数据里加一个requestId,前端渲染推荐卡片时埋点上传“展示了哪些推荐位的哪些图书”,用户点击图书详情时上传click事件。通过分析“点击数/曝光数”,就能知道推荐的准确率。这个指标我内部叫“推荐位点击率”,实战中我的冷启动方案点击率大约8%,个性化推荐方案点击率能达到20%到25%。
调优时最常干的事就是调评分公式的权重。我写了一个轻量的A/B测试接口:给用户随机分配策略A或策略B,分流各50%,然后对比两个策略下的点击率差。有一次测试发现把category_match_score的权重从0.4提到0.6,点击率反而下降了5%,原因是推荐结果过于集中在用户已有的兴趣里,缺乏“适度惊喜”。后来我用了一个折中方案:Top3分类推荐占80%,额外从用户兴趣边缘的2个分类里各抽1本好书,占20%。这个“探索与利用”的平衡,让点击率又涨了一截。
6. 系统部署与后续扩展方向
6.1 本地运行与打包部署
整个系统打包部署的过程比较简单:Maven打包成jar,直接java -jar运行。需要注意的一点是,数据库初始化SQL脚本要提前执行,我放在项目根目录的db/init.sql里。第一次启动前,把application.yml里的数据库账号密码改成自己的,Redis地址和密码也要对应修改。
还有一个容易被忽略的点:application.yml里设置spring.jackson.date-format和time-zone,不然前端拿到的时间格式会有8小时时差,显示起来很怪异。我设置的是:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这个配置不写,LocalDateTime序列化出来是一串数组,前端要特判才能显示。把这些细节配置提前弄好,部署时能少很多麻烦。
6.2 后续可以扩展的方向
当前系统已经能跑通完整流程,但如果想让推荐效果更进一步,有几个扩展方向可以考虑。
一是引入协同过滤算法。目前的推荐是“基于内容”的,本质上没有利用“其他用户的行为”。协同过滤的思路是:找到和我行为相似的用户群体,把他们在读的书推给我。这个实现不复杂,维护一个用户-图书评分矩阵,用皮尔逊相关系数或余弦相似度算用户相似度,就可以做到“和你品味相似的人也在看”。
二是图书内容的深度特征提取。比如基于图书简介做关键词提取和文本向量化,用TF-IDF或者简单的词向量模型,把书映射成向量,再用余弦相似度算“这本书和那本书有多像”,升级关联推荐的效果。
三是引入大数据量级的推荐排序模型。如果数据量大了,可以尝试用XGBoost、LightGBM做learning to rank,输入特征包括用户历史行为、图书属性、上下文信息,排序效果上限会更高。但这对于课程设计级别的项目来说有些“杀鸡用牛刀”了,有精力的同学可以当作进阶挑战。
从零把这个系统搭起来的过程中,我最深的体会是:推荐系统不管算法多花哨,本质都是给用户提供“在正确时间出现的有用信息”。数据质量决定了推荐质量的上限。所有花在行为日志采集、数据清洗、标签构建上的功夫,都会在推荐效果上得到回报。反过来说,如果数据是脏的,再高级的算法也救不回来。所以我把这个项目的大部分精力都放在了基础数据规范上,算法反而用的是简单可解释的方案。对于要交作业的同学,这个思路值得参考:先把骨架打稳,再考虑加装饰。