简介:这是一份基于SpringBoot与Vue开发的书评系统毕业设计源码包,面向计算机相关专业学生,可满足毕业设计、课程设计、项目演示及初期立项需求,也适合作为SpringBoot与Vue前后端分离项目的入门范例。项目代码已按前后端分离结构组织,后端业务逻辑、前端页面组件、数据库初始化脚本及配置文件均包含在内,并附有部署说明与README,方便从零搭建运行环境。资源包共93个文件,以Java源码(53个)、Vue组件(17个)和JS文件(6个)为主,另有SQL脚本、yaml配置、Markdown文档等,整体约90KB,目录层级清楚,便于按模块查阅。据作者说明,源码已在macOS和Windows 10/11下测试运行成功,并获导师认可、答辩评分95分,完成度较高;读者可直接用于毕设/课设交付,也可在现有功能上二次扩展。目前已有124人学习下载,适合需要快速搭建书评系统或参考完整毕设项目结构的开发学习者。
1. 书评系统选SpringBoot+Vue,评审老师到底在看什么?
每年到了毕设季,SpringBoot+Vue 几乎成了 Java 方向的默认套餐,书评系统又是这个搭配里出现频率最高的一类题目。原因不难理解:它既有用户注册登录,又有图书管理和评论打分,前后端交互足够完整,规模又控制在两三个月能写完的范围内。比起商城、论坛这类老面孔,书评系统的业务边界更清晰,读者评论和评分的操作语义也容易讲明白。
这个项目真正要解决的是三个问题:一是让用户围绕一本书产生内容,也就是评论和评分;二是让这些内容能被管理、被展示、被检索;三是把前后端分离开发的整个流程走通,包括权限控制、接口文档和部署。做的时候你会发现,工作量的大头不在 CRUD,而在评论的评分联动、敏感词过滤、重复提交防护这些细节上。这篇文章按照后端设计、前端实现、部署落地的顺序把完整方案拆开讲,最后给出三个能让答辩加分的进阶点。
2. SpringBoot后端设计:表结构先行,再写注册登录和评论接口
2.1 书评系统的数据模型:四张核心表和一张扩展表
后端动手前,先把表结构定下来。书评系统的核心实体是用户、图书、评论,而评分字段放在评论表里而不是图书表里,这是第一个容易踩坑的地方。如果只在图书表里放一个平均分,就无法追溯是谁打了分,也无法防止一个用户对同一本书重复评分。
常见的表设计如下:
| 表名 | 核心字段 | 说明 |
|---|---|---|
| sys_user | id, username, password, nickname, avatar, create_time | 用户表,password 存 BCrypt 密文 |
| book | id, isbn, title, author, publisher, cover, description, avg_rating, rating_count | 图书表,avg_rating 由评论聚合得出 |
| book_comment | id, book_id, user_id, content, rating, status, create_time | 评论表,rating 取值 1~5 |
| book_collect | id, book_id, user_id, create_time | 收藏表,业务扩展用 |
这里的关键设计是:对同一本书,一个用户只能有一条有效评论。这条约束可以在应用层判断,也可以在数据库层用唯一索引配合逻辑删除来实现。我一般建议在 book_comment 表上建一个联合唯一索引(book_id + user_id),同时在表里保留 status 字段做软删除。这样用户删除评论后还能重新评论,不会因为物理删除让联合索引失去意义。
2.1.1 为什么评分字段要冗余到图书表?
avg_rating 和 rating_count 是典型的冗余字段。每次查询图书列表时,如果都要实时计算评论表的 AVG 和 COUNT,数据量上去后响应时间会明显变差。这两个字段的维护放在评论新增、修改、删除的地方,事务里同步更新,最后再做定时任务兜底校正。这个「冗余 + 事务更新 + 定时校正」的组合,既保证了查询性能,又能在数据异常时自愈。
2.2 SpringBoot工程结构和JWT令牌的全链路打造
工程结构推荐按业务模块分包,而不是按技术层次分包。区别在于,按 controller/service/mapper 分包,找某个功能的代码要跨三个包来回跳;按 user/book/comment 分包,每个业务包内自带 controller、service、mapper,内聚性更好。
com.example.bookreview ├── common # 统一返回体、异常处理、工具类 ├── config # 配置类,如 MyBatis-Plus、拦截器注册 ├── security # JWT 工具、拦截器、用户上下文 ├── module │ ├── user # 注册、登录、个人信息 │ ├── book # 图书增删改查、分页查询 │ └── comment # 评论发布、评论列表、评分聚合2.2.1 JWT登录认证的实现,前端准备好的那些接口都是幌子
登录流程里,后端需要做三件事:校验用户名密码、签发 JWT、在拦截器里解析 JWT。用 JJWT 库来生成和解析令牌,核心代码是这几十行。
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private Long expire; // 单位:秒 // 生成令牌:把 userId 作为 subject,username 放入 claims public String createToken(Long userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expire * 1000)) .signWith(SignatureAlgorithm.HS256, secret.getBytes(StandardCharsets.UTF_8)) .compact(); } // 解析令牌:校验签名和有效期,失败直接抛异常 public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody(); } }这里有两个参数很容易出错。jwt.secret不能用默认值,部署时要通过环境变量或配置中心注入,源码里不要出现明文密钥。expire建议设成 7200 秒即两小时,太短会导致用户频繁重新登录,太长则有令牌泄露风险。注册接口成功后再直接签发令牌返回,前端省一次额外登录请求。
2.2.2 拦截器里做了哪些事?
拦截器要做两件事:放行白名单、校验非白名单请求的 token。白名单包括登录、注册、图书列表和图书详情,这些接口匿名可访问;评论发布、收藏操作则必须携带有效 token。
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (request.getMethod().equals("OPTIONS")) { return true; // 预检请求直接放行 } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { String realToken = token.substring(7); Claims claims = jwtUtil.parseToken(realToken); if (claims != null) { UserContext.setUserId(Long.parseLong(claims.getSubject())); return true; } } response.setStatus(401); return false; } }拦截器里最容易漏的是 OPTIONS 请求的处理。前端跨域访问时,浏览器会先发 OPTIONS 预检,这个请求不带 Authorization 头,如果不放行,前端会收到 401 而不是真正的业务返回。把 OPTIONS 放行,再把跨域配置和拦截器注册放在一起,这条链路才算完整。
2.3 发布书评接口:评分、内容校验和用户身份怎么拧在一起
发布评论是评论模块最核心的接口。它的逻辑链路是:从 UserContext 拿到当前用户 ID,校验该用户是否已评论过这本书,再校验评分范围,然后插入记录并同步更新图书表的 avg_rating 和 rating_count。之所以用 UserContext 而不是让前端传 userId,是因为后端必须信任登录态而不是前端参数,否则随便传一个 userId 就能冒充他人评论。
@PostMapping("/comment") public Result<?> addComment(@RequestBody @Valid CommentDTO dto) { Long userId = UserContext.getUserId(); // 参数校验:评分必须在1到5之间 if (dto.getRating() < 1 || dto.getRating() > 5) { return Result.error("评分必须在1到5之间"); } // 校验评论内容长度,比如最多1000字 if (dto.getContent().length() > 1000) { return Result.error("评论内容过长"); } // 检查是否已评论过 LambdaQueryWrapper<BookComment> wrapper = Wrappers.lambdaQuery(); wrapper.eq(BookComment::getBookId, dto.getBookId()) .eq(BookComment::getUserId, userId) .eq(BookComment::getStatus, 1); if (commentMapper.selectCount(wrapper) > 0) { return Result.error("您已评论过这本书"); } return commentService.addComment(userId, dto); }接口里用了 JSR 303 的 @Valid 注解,但评分范围这种业务规则还是用显式判断更直观。评论发布是写操作,必须放在事务里:插入 book_comment 和更新 book 表要么同时成功要么同时失败。事务的边界应该在 service 层,不在 controller 层,controller 只负责参数接收和结果封装。
更新图书评分的 SQL 可以用一条 UPDATE 语句实现:
UPDATE book SET avg_rating = (SELECT ROUND(AVG(rating), 1) FROM book_comment WHERE book_id = #{bookId} AND status = 1), rating_count = (SELECT COUNT(*) FROM book_comment WHERE book_id = #{bookId} AND status = 1) WHERE id = #{bookId}这种写法的好处是无论新增、修改还是删除评论,都执行同一套重算逻辑。用 ROUND 保留一位小数,前端展示时就不用再做格式处理。注意子查询里必须带上 status = 1 条件,软删除的评论不算入评分。
2.4 敏感词过滤:用DFA算法在拦截器里做第一次拦截
书评系统的评论内容是 UGC,答辩时几乎必被问到「如何过滤不当内容」。最直接的方案是维护一个敏感词表,用 DFA 算法(确定有限状态自动机)做匹配。DFA 的原理是预先构建一颗由敏感词组成的字典树,匹配时逐字读取输入文本,沿着字典树路径前进。
先说依赖选型。搜热词能看到 springboot 集成 hanlp 分词的话题,说明这个方向从业人员常遇到。对毕设来说,hanlp 太重了,分词 + 词性标注的开销对于短文本评论过滤没有必要。我一般建议用 Hutool 自带的 WordTree,或者手写几十行 DFA 实现。Hutool 的 WordTree 已经封装了构建和匹配,用起来是这样:
public class SensitiveWordFilter { private final WordTree wordTree = new WordTree(); public void loadSensitiveWords(List<String> words) { for (String word : words) { wordTree.addWord(word); } } // 返回文本中命中的敏感词列表 public List<String> findSensitive(String text) { return wordTree.matchAll(text, -1, false, false); } // 将敏感词替换为 * 号 public String replaceSensitive(String text) { List<String> hitWords = wordTree.matchAll(text, -1, false, false); for (String word : hitWords) { text = text.replace(word, "*".repeat(word.length())); } return text; } }命中了敏感词怎么办?常见做法是直接拒绝发布,或者把敏感词替换成星号后允许发布。对毕设而言,「替换后发布」更体现系统的宽容度,也不会让评审觉得你写得一刀切。替换策略会带来另一个问题:用户可能用谐音字绕过,比如把「sb」写成「s b」。要处理这类变形,就得在过滤前做归一化,把全角转半角、删除空格,再做匹配。敏感词表可以放在数据库表里,后台管理页面支持动态添加,比写死在代码里更有说服力。
3. Vue前端开发:从Axios封装到路由守卫的完整闭环
3.1 Vue工程结构和开发环境准备
前端这边,Vue 3 + Vite 是当前的主流选择,script setup 语法比 Options API 简洁得多,也更容易在答辩时讲清楚。开发环境的坑主要集中在 Node 版本上,Vite 5 要求 Node 18+,装了老版本 Node 会直接报错。用 nvm 管理 Node 版本,避免环境变量配置这类体力活消耗时间。
工程结构按页面和组件划分,推荐下面这种:
src ├── api # 按模块拆分的接口调用文件 ├── assets # 静态资源 ├── components # 通用组件,如评分星标 ├── router # 路由配置 ├── store # Pinia 状态管理 └── views # 页面级组件 ├── BookList.vue ├── BookDetail.vue └── Login.vue3.2 Axios实例封装:统一处理token和错误码
前端所有接口调用应该共用一个 Axios 实例,request 拦截器里统一加 token,response 拦截器里统一处理业务错误码和 401。不封装的话,每个页面都要重复写一段从 localStorage 取 token 的逻辑,代码量大且容易漏。
// api/request.js import axios from 'axios' import { message } from 'ant-design-vue' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:携带token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一处理错误 request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { message.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res }, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } message.error('网络异常,请稍后重试') return Promise.reject(error) } )拦截器里两个要点。第一,baseURL 用/api而不是完整地址,这样可以配合后面 Nginx 的转发规则,避免在代码里写死环境地址。第二,401 处理要放在响应拦截器而不是每个页面里,毕竟登录状态过期可能发生在任何一个接口上。注意这里没有直接引入 router,在 request.js 里引入 router 会导致循环依赖,我一般用window.location.href = '/login'代替。
3.2.1 路由参数在书评系统里的正确用法
图书详情页从列表页跳转时,需要携带图书 ID。Vue Router 4 里有两种传参方式:路径参数和 query 参数。路径参数更规范,适合这种详情页跳转。
// router/index.js const routes = [ { path: '/book/:id', name: 'BookDetail', component: BookDetail }, { path: '/', name: 'BookList', component: BookList }, { path: '/login', name: 'Login', component: Login } ]// BookList.vue 跳转详情 const goDetail = (id) => { router.push({ name: 'BookDetail', params: { id } }) }// BookDetail.vue 取参数 import { useRoute } from 'vue-router' const route = useRoute() const bookId = route.params.id这种方式刷新页面后参数不会丢,因为 ID 是 URL 路径的一部分,安全性也好于把敏感参数放 query 里。
3.3 路由守卫:登录校验和页面权限怎么落地
前端路由守卫要判断的是「哪些页面必须登录后才能访问」。发布评论、个人中心、收藏列表都要登录,图书列表和详情页允许匿名。给每个路由加一个 meta 字段,用meta.requiresAuth标记是否需要登录。
// router/index.js 全局前置守卫 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ name: 'Login', query: { redirect: to.fullPath } }) } else { next() } })带一个 redirect 参数,用户登录后还能回到原来的页面,这是很常见但经常被忽略细节。登录页拿到 redirect 后,在登录成功的回调里跳转:
const route = useRoute() const redirect = route.query.redirect || '/' router.push(redirect)路由守卫和 JWT 拦截器构成了两层防护。前端守卫负责体验,未登录直接跳登录页;后端拦截器负责安全,即便绕过前端伪造请求也会在服务端被拦住。这两层可以分开实现,但都不能少。
3.4 书评列表与发布表单的核心实现
书评列表是评论模块的门面,核心是分页获取数据和对应星标评分展示。评分展示建议单独抽成组件,用 SVG 或图标库实现即可。
评论发布表单的核心问题是「评分 + 文本内容」的联动。用户先选星标再输入文本,提交时一次发送。评分组件可以用 elect 组件,也可以用自己写的星标点击组件。setUp 语法下,v-model 绑定组件内部 prop 的写法要注意,多写一个 emit('update:rating') 才能让 v-model 生效。
发布成功后要做两件事:提示成功并清空表单,然后刷新评论列表;同时更新图书详情页的评分数据。从接口返回里拿到新的图书评分,直接赋值,不需要重新请求图书详情。
4. 部署落地:从打包到Nginx上线的完整路径
4.1 部署前要解决的跨域和配置外置问题
前端开发环境用 Vite 的 proxy 代理访问后端,生产环境用 Nginx 做转发,两个环境都不需要后端开启 CORS。这是贯穿前后端开发到部署的一条主线,如果每个环境都靠后端加@CrossOrigin注解解决跨域,环境一多就会失控。常见做法是:本地开发用 proxy,服务器上用 Nginx 转发,后端完全不写跨域代码。
配置外置指的是把数据库连接、JWT 密钥、端口号这些「与环境相关」的配置放进 application-prod.yml,打包时通过--spring.profiles.active=prod指定。或者更严格一点,用@Value("${db.password}")配合环境变量注入,让配置文件里不出现明文密码。SpringBoot 的配置优先级里环境变量的优先级高于配置文件,利用这一点可以做到一套代码多环境部署。
4.2 Maven打包和Jar包启动的完整命令
后端打包用 Maven 的 package 生命周期。打包前先跑测试会拖慢构建,毕设项目的单元测试本来就有限,我一般直接跳过。
# 跳过测试打包 mvn clean package -DskipTests # 启动生产环境配置 java -jar book-review-server.jar --spring.profiles.active=prod # 后台运行并输出日志到文件 nohup java -jar book-review-server.jar --spring.profiles.active=prod > app.log 2>&1 & # 查看日志确认启动状态 tail -f app.lognohup是 no hang up 的缩写,让进程在终端关闭后继续运行;> app.log把标准输出重定向到文件;2>&1把错误输出也合入同一个文件。启动后重点看日志里Started Application in xx seconds这行,确认端口没有被占用。用lsof -i:8080或netstat -tunlp | grep 8080查看端口监听情况。
4.2.1 SpringBoot版本选择的一个现实问题
搜热词里有 springboot 版本太高导致的问题。SpringBoot 3.x 要求 JDK 17,而很多公司的服务器还在用 JDK 8,这是一个很现实的环境冲突。毕设项目如果本地是 JDK 8,就选 SpringBoot 2.7.x,这是 2.x 最后的维护分支;如果本地是 JDK 17,可以选 3.x。确定版本后,pom.xml 里对应的一堆依赖版本不要随意升级,特别是 MyBatis-Plus 的适配版本,高版本 SpringBoot 配合低版本 MyBatis-Plus 会报各种反射相关的错误。
4.3 Nginx托管前端并转发API请求
前端打包产物是静态文件,用 Nginx 托管。打包命令是npm run build,产物在 dist 目录。把 dist 目录传到服务器的/usr/share/nginx/book-review下,再写一份 Nginx 配置。
server { listen 80; server_name your-domain.com; # 前端静态资源 root /usr/share/nginx/book-review; index index.html; # 历史路由支持:刷新页面不404 location / { try_files $uri $uri/ /index.html; } # API请求转发到后端 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html这行是 Vue Router history 模式的关键,少了它刷新子页面就是 404。proxy_pass http://127.0.0.1:8080/末尾的/会把/api前缀去掉转发,比如前端请求/api/book/list,后端实际收到的是/book/list,所以后端的 Controller 路径里不需要加/api前缀。
配置改动后执行nginx -s reload刷新,不需要重启 Nginx 进程。用nginx -t先校验语法再 reload,是稳妥习惯。
4.4 部署文档里最容易翻车的三个地方
部署文档是标题里明确提到的交付物,三个高频问题值得专门处理。
第一个是端口冲突。服务器上可能有多个 Java 进程占用 8080,启动前先检查端口,或者直接换一个不常用的端口如 8081,并在 Nginx 转发里相应修改。
第二个是 MySQL 时区问题。JDBC URL 里没加时区参数,或者 MySQL 默认时区不是东八区,会导致时间字段差了 8 个小时。连接串里加上serverTimezone=Asia/Shanghai,建表语句里用DEFAULT CURRENT_TIMESTAMP,前后统一就不会乱。
第三个是静态资源路径。Vue 打包后,图片和 CSS 引用的路径默认是绝对路径,部署后资源加载 404。在 vite.config.js 里设置base: './',让打包产物改用相对路径。
5. 答辩加分项:评分聚合、评论防刷与Redis缓存
5.1 评分聚合的SQL与触发器的取舍
前面更新图书评分的 UPDATE 子查询是最常用方案。答辩时老师可能会追问「并发条件下如何保证评分准确?」可以补充一个悲观锁方案:更新前先SELECT rating FROM book WHERE id = ? FOR UPDATE,锁住图书记录,再重算评分。这样同一时刻只有一个请求在写评分,数据一致性最强,代价是吞吐量下降。
触发器是另一个可选方案,在 book_comment 表上建 AFTER INSERT 触发器,自动更新 book 表。触发器的好处是应用层无需感知,缺点是不好调试、迁移麻烦。我一般建议用事务里的显式更新,毕竟毕设要现场演示,出问题时好排查。
5.2 评论防刷:时间窗和IP维度
评分刷屏是书评系统实际运营中一定会遇到的问题。毕设项目只要代码里能体现出「考虑过这个问题」,就是加分项。最简单的防刷策略是时间窗限制:同一个用户 60 秒内只能发布一条评论。用 Redis 的 SETNX 或数据库的时间戳判断都能实现。
// 用Redis实现评论时间窗限制 String key = "comment:limit:" + userId; Boolean canPublish = stringRedisTemplate.opsForValue() .setIfAbsent(key, "1", Duration.ofSeconds(60)); if (Boolean.FALSE.equals(canPublish)) { return Result.error("评论过于频繁,请稍后再试"); }setIfAbsent的意思是「只有当 key 不存在时才写入」,配合过期时间正好实现滑动窗口的效果。第一次请求写入成功返回 true,60 秒内的后续请求都返回 false。注意 Redis 返回的 Boolean 可能为 null(网络异常时),所以用Boolean.FALSE.equals(canPublish)而不是!canPublish,避免拆箱空指针。这是开发中容易踩的细节,答辩时主动提出来会显得有经验。
5.3 用Redis缓存热门书评,缓存一致性怎么保证
图书详情页的书评列表是热点数据,每次刷新都要查库。缓存方案是:按 bookId 做 key,第一次查询时把结果写入 Redis,设置 5 分钟过期;发布新评论后主动删除该书的缓存,下次查询重新加载。
public List<BookCommentVO> getCommentList(Long bookId) { String key = "book:comment:" + bookId; String cached = stringRedisTemplate.opsForValue().get(key); if (cached != null) { return JSON.parseArray(cached, BookCommentVO.class); } List<BookCommentVO> list = commentMapper.selectByBookId(bookId); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(list), Duration.ofMinutes(5)); return list; } // 发布评论成功后删除缓存 // 不加睡眠等待,直接删除,下次请求重新加载 stringRedisTemplate.delete("book:comment:" + bookId);缓存策略里「删除缓存而不是更新缓存」是经验之谈。如果先更新数据库再更新缓存,两个操作不在同一个事务里,出错了就是一份新的脏数据;而删除缓存最多导致下次查询多查一次库,不会产生数据不一致。写入缓存时记得统一设置过期时间,万一缓存里的脏数据没被删掉,也会在过期后自动淘汰。
答辩时如果被问到 Redis 缓存穿透,可以补充一个回答要点:对不存在 bookId 的请求,也缓存空值并设置短过期时间,避免恶意请求绕过缓存直接打数据库。这一点能体现对生产环境问题的认知,比单纯说「用了缓存」更有说服力。
本文还有配套的精品资源,点击获取