Spring Boot学生读书笔记共享系统:从数据模型到部署的完整实现

发布时间:2026/10/30 15:51:15
Spring Boot学生读书笔记共享系统:从数据模型到部署的完整实现

简介:本资源为基于SpringBoot的学生读书笔记共享系统完整课设项目,面向计算机相关专业学生与Java初学者,可用于课程设计、毕业设计参考或SpringBoot入门实战。项目采用Java语言开发,后端基于SpringBoot框架,数据存储使用MySQL,前端页面配合Vue与HTML实现,涵盖管理员、前台首页与用户三大功能模块,并附有完整论文文档,从绪论、技术介绍、需求分析到系统设计、实现与测试逐章展开,包含实体ER图与数据表设计。压缩包共784个文件,以js、svg、java、vue、css、html等源码与页面资源为主,另含sql建库脚本、bat启动脚本及docx论文,整体约19.18MB,目录结构清晰,便于按模块查阅与二次开发。目前已有32人学习下载,适合需要完整赛题方案、论文写作参考与项目排错思路的读者。

1. 从一份 7z 压缩包说起:学生读书笔记共享系统到底解决什么问题

很多计算机专业的同学在做课程设计或毕业设计时,都会拿到类似「基于 Spring Boot 学生读书笔记共享设计与实现」这样的题目,附带一个 7z 压缩包,里面是源码加论文。第一反应往往是:这不就是个增删改查的博客吗?但真正动手做起来,你会发现它比想象中要复杂——读书笔记共享涉及用户权限隔离、笔记的公开/私密可见性、书籍信息与笔记的关联、以及共享场景下的检索和推荐。它要解决的核心问题是:让一群学生把自己的读书笔记结构化地存起来,并且能按书籍、标签、用户三个维度互相查看和复用。

这套系统适合谁?一是正在做 Java 课程设计、需要一套完整可运行项目参考的在校生;二是想用 Spring Boot 练手一个「有业务逻辑但不至于太庞大」的全栈项目的开发者。它不像电商系统那样涉及复杂的订单和支付,也不像社交平台那样需要处理海量消息,但它的业务边界清晰、数据模型直观,非常适合用来理解 Spring Boot 分层架构、MyBatis 持久化和前后端分离的完整链路。接下来我会按「数据模型怎么设计 → 后端怎么搭 → 前端怎么接 → 坑在哪」的顺序,把这条路走一遍。

2. 数据模型与权限设计:笔记共享系统的骨架怎么搭

2.1 四张核心表撑起整个业务

读书笔记共享系统的数据模型并不复杂,但每张表的字段设计直接决定了后续功能能不能顺利扩展。我一般会先画清楚实体关系:一个用户可以有多个书架,一个书架里有多本书,一本书对应多条笔记,一条笔记可以打多个标签。落到数据库层面,核心表大概是这四张:

表名关键字段说明
userid, username, password, nickname, avatar, role用户表,role 区分学生和管理员
bookid, title, author, isbn, cover_url, category书籍信息表,isbn 用于去重
noteid, user_id, book_id, title, content, visibility, create_time笔记表,visibility 控制公开/私密
tagid, name标签表,配合 note_tag 关联表使用

这里有个容易忽略的点:note 表的 visibility 字段。很多同学一开始只做「公开」和「私密」两种状态,但实际使用中会出现「仅同班同学可见」这种需求。我的建议是先用枚举值 0/1/2 分别代表私密、公开、好友可见,后续要扩展只需要加一个 note_visibility_rule 表即可,不用改主表结构。

书籍表里的 isbn 字段也值得多说一句。学生在录入书籍时,手动输入书名很容易出现同一本书多个条目。用 ISBN 做唯一索引,配合前端调用公开的图书 API 自动填充书名和作者,能大幅减少脏数据。如果 ISBN 为空,再回退到「书名+作者」的联合唯一索引。

2.2 权限控制不是加个拦截器就完事

Spring Boot 项目里做权限,最常见的做法是 Spring Security 加 JWT。但这套系统有个特殊之处:笔记的可见性是行级权限,不是简单的接口级权限。也就是说,同一个/api/note/{id}接口,A 用户请求自己的私密笔记应该返回数据,B 用户请求同一条笔记应该返回 403。这种行级权限用拦截器很难优雅处理,我的做法是在 Service 层做校验:

// NoteServiceImpl.java public NoteVO getNoteById(Long noteId, Long currentUserId) { Note note = noteMapper.selectById(noteId); if (note == null) { throw new BusinessException("笔记不存在"); } // 行级权限判断:私密笔记只有作者本人可见 if (note.getVisibility() == 0 && !note.getUserId().equals(currentUserId)) { throw new BusinessException(403, "无权查看该笔记"); } // 好友可见的笔记需要查关系表 if (note.getVisibility() == 2 && !isFriend(note.getUserId(), currentUserId)) { throw new BusinessException(403, "无权查看该笔记"); } return convertToVO(note); }

这段代码的逻辑说明:先查笔记是否存在,再根据 visibility 字段做分支判断。参数方面,noteId 是路径参数,currentUserId 从 JWT token 中解析后通过 ThreadLocal 或方法参数传入。注意这里没有用 Spring Security 的 @PreAuthorize,因为行级权限依赖具体数据,注解表达式写起来很别扭,不如在 Service 层显式判断来得清晰。

提示:JWT 的 payload 里只放 userId 和 role,不要放权限列表。权限列表一旦变化,旧 token 就无法及时失效,行级权限判断必须每次查库。

2.3 笔记与标签的多对多关系怎么落

标签功能看起来简单,但查询「带有某个标签的所有公开笔记」时,如果 SQL 写不好,很容易出现 N+1 问题。我的做法是建一张 note_tag 关联表,然后在 MyBatis 的 XML 里用一次 JOIN 查询搞定:

<!-- NoteMapper.xml --> <select id="selectPublicNotesByTag" resultMap="NoteVOMap"> SELECT n.*, GROUP_CONCAT(t.name) AS tag_names FROM note n LEFT JOIN note_tag nt ON n.id = nt.note_id LEFT JOIN tag t ON nt.tag_id = t.id WHERE n.visibility = 1 AND n.id IN (SELECT note_id FROM note_tag WHERE tag_id = #{tagId}) GROUP BY n.id ORDER BY n.create_time DESC LIMIT #{offset}, #{pageSize} </select>

逻辑说明:先用子查询筛出带有目标标签的笔记 ID,再回表关联标签名,用 GROUP_CONCAT 把多个标签拼成一个字符串返回给前端。参数 tagId 是标签主键,offset 和 pageSize 控制分页。这里有个坑:GROUP_CONCAT 默认长度限制是 1024 字节,如果一条笔记标签特别多,会被截断。解决办法是在 MySQL 配置里调大 group_concat_max_len,或者在 Java 层再查一次标签。

3. 后端接口与业务逻辑:Spring Boot 分层怎么切才不乱

3.1 Controller 层只做参数校验和路由

很多初学者会把业务逻辑写在 Controller 里,导致一个方法几百行。我的习惯是 Controller 只做三件事:接收参数、校验参数、调用 Service 返回结果。参数校验用 Jakarta Validation 注解,比如@NotBlank、@Size,配合全局异常处理器统一返回错误信息。

// NoteController.java @RestController @RequestMapping("/api/note") public class NoteController { @Autowired private NoteService noteService; @PostMapping public Result<NoteVO> createNote(@Valid @RequestBody NoteCreateDTO dto) { Long userId = UserContext.getCurrentUserId(); NoteVO vo = noteService.createNote(dto, userId); return Result.success(vo); } @GetMapping("/public") public Result<PageResult<NoteVO>> listPublicNotes( @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) Long tagId) { return Result.success(noteService.listPublicNotes(page, size, tagId)); } }

逻辑说明:createNote 接口从 UserContext 中取当前用户 ID,这个 UserContext 是在 JWT 拦截器里设置的 ThreadLocal。listPublicNotes 接口支持分页和标签筛选,page 和 size 有默认值,tagId 可选。参数校验失败会抛出 MethodArgumentNotValidException,由全局异常处理器捕获后返回统一格式。

注意:ThreadLocal 用完一定要在拦截器的 afterCompletion 里 remove,否则线程池复用时会串数据,这是血泪教训。

3.2 Service 层的事务边界要卡准

读书笔记系统里涉及多表写入的操作有两个:创建笔记时同时写 note 表和 note_tag 表;删除笔记时同时删两张表。这两个操作必须在一个事务里。Spring Boot 里加@Transactional注解即可,但要注意默认只对 RuntimeException 回滚,如果抛的是 checked exception,需要手动指定 rollbackFor。

// NoteServiceImpl.java @Transactional(rollbackFor = Exception.class) public NoteVO createNote(NoteCreateDTO dto, Long userId) { Note note = new Note(); note.setUserId(userId); note.setBookId(dto.getBookId()); note.setTitle(dto.getTitle()); note.setContent(dto.getContent()); note.setVisibility(dto.getVisibility()); note.setCreateTime(LocalDateTime.now()); noteMapper.insert(note); // 批量插入标签关联 if (CollectionUtils.isNotEmpty(dto.getTagIds())) { List<NoteTag> noteTags = dto.getTagIds().stream() .map(tagId -> new NoteTag(note.getId(), tagId)) .collect(Collectors.toList()); noteTagMapper.batchInsert(noteTags); } return convertToVO(note); }

逻辑说明:先插入笔记主记录,MyBatis 的 useGeneratedKeys 会把自增 ID 回填到 note 对象里,然后用这个 ID 构建标签关联列表批量插入。参数 dto 里的 tagIds 是前端传来的标签 ID 数组,可能为空,所以加了判空。rollbackFor 设为 Exception.class 是为了确保任何异常都回滚,避免出现笔记插入了但标签没插入的脏数据。

3.3 分页查询用 PageHelper 还是手写 LIMIT

这是被问得最多的问题之一。PageHelper 的好处是代码简洁,一行PageHelper.startPage(page, size)就能搞定,但它对 SQL 有侵入性,复杂查询时容易出问题。我的建议是:简单单表查询用 PageHelper,多表 JOIN 的复杂查询手写 LIMIT。

// 手写分页的 Service 方法 public PageResult<NoteVO> listPublicNotes(Integer page, Integer size, Long tagId) { int offset = (page - 1) * size; List<NoteVO> list = noteMapper.selectPublicNotesByTag(tagId, offset, size); long total = noteMapper.countPublicNotesByTag(tagId); return new PageResult<>(list, total, page, size); }

逻辑说明:offset 根据页码和每页条数计算,传给 MyBatis 的 LIMIT 子句。total 单独查一次 COUNT,虽然多了一次查询,但 SQL 可控,不会出现 PageHelper 在某些复杂场景下 count 语句生成错误的问题。参数 page 从 1 开始,size 建议限制在 50 以内,防止前端传个 10000 把数据库拖垮。

4. 前端对接与部署:Vue 打包放进 Spring Boot 的完整链路

4.1 前后端分离还是合并部署

课程设计阶段,我强烈建议用前后端分离开发、合并部署的方式。开发时 Vue 跑在 5173 端口,通过 Vite 的 proxy 转发 API 请求到 Spring Boot 的 8080 端口,这样前后端可以独立调试。部署时把 Vue 打包产物放到 Spring Boot 的src/main/resources/static目录下,打成一个 jar 包,用户只需要java -jar就能跑起来。

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

逻辑说明:所有以 /api 开头的请求都会被代理到后端 8080 端口,changeOrigin 设为 true 是为了让后端收到的 Host 头是 localhost:8080 而不是 localhost:5173,避免某些安全校验失败。开发阶段这样配置就够了,不需要处理跨域。

4.2 打包合并时的静态资源路径问题

Vue 打包后默认的 publicPath 是/,如果 Spring Boot 的 context-path 不是根路径,就会出现 404。解决办法是在 vite.config.js 里设置base: './',让资源用相对路径引用。然后 Spring Boot 这边需要加一个配置类,把前端路由的 404 请求转发到 index.html:

// WebMvcConfig.java @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/**") .addResourceLocations("classpath:/static/"); } @Override public void addViewControllers(ViewControllerRegistry registry) { // 把非 API 请求都转发到 index.html,支持 Vue Router 的 history 模式 registry.addViewController("/{path:[^\\.]*}") .setViewName("forward:/index.html"); } }

逻辑说明:addResourceHandlers 把静态资源映射到 classpath:/static/ 目录,addViewControllers 用正则匹配不含点号的路径,转发到 index.html。这样用户在浏览器里直接访问/note/123这种前端路由时,不会报 404。参数方面,正则[^\\.]*表示不包含点号的任意字符,确保 .js、.css 等静态文件不会被错误转发。

提示:如果用了 Vue Router 的 hash 模式,这一步可以省略,但 URL 里会带 #,不太好看。history 模式需要后端配合,这是常见做法。

4.3 文件上传与头像存储

读书笔记系统里,用户头像和笔记封面图需要上传。课程设计阶段不建议接对象存储,直接用本地磁盘存,数据库里存相对路径即可。Spring Boot 处理 MultipartFile 的默认大小限制是 1MB,需要在 application.yml 里调大:

spring: servlet: multipart: max-file-size: 5MB max-request-size: 10MB

上传接口的写法:

@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { throw new BusinessException("文件为空"); } String originalName = file.getOriginalFilename(); String suffix = originalName.substring(originalName.lastIndexOf(".")); String newName = UUID.randomUUID().toString() + suffix; Path path = Paths.get(uploadDir, newName); Files.createDirectories(path.getParent()); file.transferTo(path.toFile()); return Result.success("/upload/" + newName); }

逻辑说明:用 UUID 重命名文件避免同名覆盖,保留原始后缀。uploadDir 从配置文件读取,一般是./upload/。返回的路径是相对路径,前端拼接域名后访问。参数方面,max-file-size 控制单个文件大小,max-request-size 控制整个请求的大小,如果一次上传多个文件,后者要设得更大。

5. 避坑与排查:这套系统最容易翻车的五个地方

5.1 启动报错「Table ‘xxx’ doesn‘t exist」

现象:Spring Boot 启动成功,但访问接口时报错,提示某张表不存在。原因:JPA 或 MyBatis-Plus 的自动建表没有生效,或者数据库连接指向了错误的库。解决:检查 application.yml 里的spring.datasource.url是否指向了正确的数据库名,然后手动执行 schema.sql 建表。如果用的是 MyBatis-Plus,确认@TableName注解里的表名和数据库一致。

5.2 中文乱码从数据库一路乱到前端

现象:笔记内容里的中文在数据库里显示正常,但接口返回给前端后变成问号。原因:数据库连接 URL 没有指定字符集,或者 Spring Boot 的 HTTP 编码配置不对。解决:在 JDBC URL 后面加?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,同时在 application.yml 里配置server.servlet.encoding.charset=UTF-8和force=true。

5.3 JWT token 过期后前端没有跳转登录页

现象:token 过期后,接口返回 401,但前端页面卡在原地,用户不知道发生了什么。原因:前端的 axios 拦截器没有处理 401 状态码。解决:在 axios 的 response 拦截器里判断 status 是否为 401,如果是就清除本地 token 并跳转到登录页。注意不要在所有 401 场景都跳转,比如登录接口本身返回 401 时不应该跳转,否则会死循环。

5.4 分页查询第二页数据重复

现象:第一页和第二页有重复数据,或者某些数据被跳过。原因:ORDER BY 的字段有相同值,MySQL 的 LIMIT 在排序不稳定时返回顺序不确定。解决:在 ORDER BY 里加上主键作为第二排序字段,比如ORDER BY create_time DESC, id DESC,确保排序唯一。

5.5 打包后前端页面空白

现象:java -jar启动后访问首页是空白,控制台报 404 找不到 js 文件。原因:Vue 打包的 publicPath 配置不对,或者 Spring Boot 的静态资源映射没生效。解决:确认 vite.config.js 里base: './',然后检查打包产物是否真的复制到了target/classes/static目录下。如果用的是 Maven,可以在 pom.xml 里配置 frontend-maven-plugin 自动执行 npm build 并复制文件。

6. 进阶技巧:用 AOP 做操作日志和笔记热度统计

这套系统做完基础功能后,如果想在论文里体现一点技术深度,我建议加两个东西:操作日志和笔记热度统计。操作日志用 Spring AOP 实现,在自定义注解标记的方法上自动记录谁在什么时间做了什么操作。热度统计则可以用 Redis 的 ZSet 来做,每次笔记被查看就ZINCRBY一次,定时把热度值同步回数据库。

// LogAspect.java @Aspect @Component public class LogAspect { @Autowired private OperationLogMapper logMapper; @Around("@annotation(operationLog)") public Object around(ProceedingJoinPoint point, OperationLog operationLog) throws Throwable { long start = System.currentTimeMillis(); Object result = point.proceed(); long cost = System.currentTimeMillis() - start; OperationLogEntity entity = new OperationLogEntity(); entity.setUserId(UserContext.getCurrentUserId()); entity.setAction(operationLog.value()); entity.setMethod(point.getSignature().toShortString()); entity.setCost(cost); entity.setCreateTime(LocalDateTime.now()); logMapper.insert(entity); return result; } }

逻辑说明:@Around 环绕通知在目标方法执行前后插入逻辑,point.proceed() 执行原方法,然后记录耗时和操作信息。参数 operationLog 是自定义注解,value 属性描述操作类型。这个切面要注意异常处理——如果原方法抛异常,point.proceed() 会直接抛出,后面的日志插入不会执行。如果希望异常也记录,需要用 try-finally 包起来。

热度统计的 Redis 操作:

// NoteServiceImpl.java public void viewNote(Long noteId) { String key = "note:hot:" + LocalDate.now(); redisTemplate.opsForZSet().incrementScore(key, noteId.toString(), 1); redisTemplate.expire(key, 7, TimeUnit.DAYS); }

逻辑说明:用日期做 key 后缀,每天一个 ZSet,避免所有数据堆在一个 key 里。incrementScore 给指定笔记的热度加一,expire 设置 7 天过期,只保留最近一周的热度数据。定时任务每天凌晨把前一天的 ZSet 数据同步到数据库的 note_hot 表,然后删除 Redis key。

注意:Redis 的 ZSet 在数据量大时内存占用会比较高,课程设计阶段数据量小没问题,生产环境需要考虑分片或改用其他方案。

最后说一个我自己的习惯:每次做完一个 Spring Boot 项目,我都会把 application.yml 里的所有配置项过一遍,把不该留的默认值改掉,比如数据库密码、Redis 地址、文件上传路径。这些东西在论文里不会写,但答辩时老师如果让你现场改配置重新部署,你就能看出平时有没有养成好习惯。希望帮到你。

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