ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot+Vue书评系统实战:从后端设计到部署上线

SpringBoot+Vue书评系统实战:从后端设计到部署上线 简介这是一份基于SpringBoot与Vue开发的书评系统毕业设计源码包面向计算机相关专业学生可满足毕业设计、课程设计、项目演示及初期立项需求也适合作为SpringBoot与Vue前后端分离项目的入门范例。项目代码已按前后端分离结构组织后端业务逻辑、前端页面组件、数据库初始化脚本及配置文件均包含在内并附有部署说明与README方便从零搭建运行环境。资源包共93个文件以Java源码53个、Vue组件17个和JS文件6个为主另有SQL脚本、yaml配置、Markdown文档等整体约90KB目录层级清楚便于按模块查阅。据作者说明源码已在macOS和Windows 10/11下测试运行成功并获导师认可、答辩评分95分完成度较高读者可直接用于毕设/课设交付也可在现有功能上二次扩展。目前已有124人学习下载适合需要快速搭建书评系统或参考完整毕设项目结构的开发学习者。1. 书评系统选SpringBootVue评审老师到底在看什么每年到了毕设季SpringBootVue 几乎成了 Java 方向的默认套餐书评系统又是这个搭配里出现频率最高的一类题目。原因不难理解它既有用户注册登录又有图书管理和评论打分前后端交互足够完整规模又控制在两三个月能写完的范围内。比起商城、论坛这类老面孔书评系统的业务边界更清晰读者评论和评分的操作语义也容易讲明白。这个项目真正要解决的是三个问题一是让用户围绕一本书产生内容也就是评论和评分二是让这些内容能被管理、被展示、被检索三是把前后端分离开发的整个流程走通包括权限控制、接口文档和部署。做的时候你会发现工作量的大头不在 CRUD而在评论的评分联动、敏感词过滤、重复提交防护这些细节上。这篇文章按照后端设计、前端实现、部署落地的顺序把完整方案拆开讲最后给出三个能让答辩加分的进阶点。2. SpringBoot后端设计表结构先行再写注册登录和评论接口2.1 书评系统的数据模型四张核心表和一张扩展表后端动手前先把表结构定下来。书评系统的核心实体是用户、图书、评论而评分字段放在评论表里而不是图书表里这是第一个容易踩坑的地方。如果只在图书表里放一个平均分就无法追溯是谁打了分也无法防止一个用户对同一本书重复评分。常见的表设计如下表名核心字段说明sys_userid, username, password, nickname, avatar, create_time用户表password 存 BCrypt 密文bookid, isbn, title, author, publisher, cover, description, avg_rating, rating_count图书表avg_rating 由评论聚合得出book_commentid, book_id, user_id, content, rating, status, create_time评论表rating 取值 1~5book_collectid, 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 作为 subjectusername 放入 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(评论内容过长); } // 检查是否已评论过 LambdaQueryWrapperBookComment 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(ListString words) { for (String word : words) { wordTree.addWord(word); } } // 返回文本中命中的敏感词列表 public ListString findSensitive(String text) { return wordTree.matchAll(text, -1, false, false); } // 将敏感词替换为 * 号 public String replaceSensitive(String text) { ListString 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 拦截器里统一加 tokenresponse 拦截器里统一处理业务错误码和 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.activeprod指定。或者更严格一点用Value(${db.password})配合环境变量注入让配置文件里不出现明文密码。SpringBoot 的配置优先级里环境变量的优先级高于配置文件利用这一点可以做到一套代码多环境部署。4.2 Maven打包和Jar包启动的完整命令后端打包用 Maven 的 package 生命周期。打包前先跑测试会拖慢构建毕设项目的单元测试本来就有限我一般直接跳过。# 跳过测试打包 mvn clean package -DskipTests # 启动生产环境配置 java -jar book-review-server.jar --spring.profiles.activeprod # 后台运行并输出日志到文件 nohup java -jar book-review-server.jar --spring.profiles.activeprod app.log 21 # 查看日志确认启动状态 tail -f app.lognohup是 no hang up 的缩写让进程在终端关闭后继续运行 app.log把标准输出重定向到文件21把错误输出也合入同一个文件。启动后重点看日志里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 个小时。连接串里加上serverTimezoneAsia/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 不存在时才写入」配合过期时间正好实现滑动窗口的效果。第一次请求写入成功返回 true60 秒内的后续请求都返回 false。注意 Redis 返回的 Boolean 可能为 null网络异常时所以用Boolean.FALSE.equals(canPublish)而不是!canPublish避免拆箱空指针。这是开发中容易踩的细节答辩时主动提出来会显得有经验。5.3 用Redis缓存热门书评缓存一致性怎么保证图书详情页的书评列表是热点数据每次刷新都要查库。缓存方案是按 bookId 做 key第一次查询时把结果写入 Redis设置 5 分钟过期发布新评论后主动删除该书的缓存下次查询重新加载。public ListBookCommentVO getCommentList(Long bookId) { String key book:comment: bookId; String cached stringRedisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseArray(cached, BookCommentVO.class); } ListBookCommentVO list commentMapper.selectByBookId(bookId); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(list), Duration.ofMinutes(5)); return list; } // 发布评论成功后删除缓存 // 不加睡眠等待直接删除下次请求重新加载 stringRedisTemplate.delete(book:comment: bookId);缓存策略里「删除缓存而不是更新缓存」是经验之谈。如果先更新数据库再更新缓存两个操作不在同一个事务里出错了就是一份新的脏数据而删除缓存最多导致下次查询多查一次库不会产生数据不一致。写入缓存时记得统一设置过期时间万一缓存里的脏数据没被删掉也会在过期后自动淘汰。答辩时如果被问到 Redis 缓存穿透可以补充一个回答要点对不存在 bookId 的请求也缓存空值并设置短过期时间避免恶意请求绕过缓存直接打数据库。这一点能体现对生产环境问题的认知比单纯说「用了缓存」更有说服力。本文还有配套的精品资源点击获取
返回列表