☰
SpringBoot+Vue图书分享系统实战:状态流转与鉴权细节
2026/9/30 15:02:14 网站建设 项目流程

上个月刚把图书分享系统做完交付,从需求分析到部署上线差不多用了三周。这个项目是基于Springboot+Vue的前后端分离实现,核心功能包括用户注册登录、图书上传、审核、借阅、评论、收藏,以及管理员后台的全流程管理。写这篇文章不是交代课设作业,而是把整个设计过程中踩过的坑、做过的取舍、以及真正影响开发效率的几个关键点梳理出来,给正在做类似Springboot+Vue项目的人当个参考。

如果是第一次接触这类系统,你可能会觉得"图书分享"不就是做个图书列表加个借阅按钮嘛。真做起来你会发现,真正的复杂度在借阅状态流转、文件上传限制、JWT鉴权、前端路由守卫这些环节。下面按我实际开发的顺序来聊,每个环节都有可复现的细节。

1. 图书分享系统的真实需求:用户贡献书库还是馆藏管理

1.1 从业务场景看功能边界

很多人拿到"图书分享系统"这个题目,第一反应是做成图书管理系统的简化版——管理员录入图书、维护库存、记录借出归还。但"分享"这两个字决定了它和传统图书馆管理有本质区别:图书来源是用户自己上传,而不是管理员统一采购。这意味着系统必须支持用户贡献内容,并且要有一个审核机制来保证上架图书的质量。

我接到的实际需求是做一个类似"图书漂流"的平台:用户注册后可以上传自己闲置的图书,包括封面图片和PDF文件,填写书名、作者、ISBN、分类、简介等信息;上传的图书经过管理员审核后正式上架,其他用户可以在平台浏览、搜索、借阅。借阅不是立刻生效的,而是产生一条借阅申请,管理员确认后图书状态变为"借出中",到期归还后再重新上架。除了借阅,用户还能评论、收藏、预约,个人中心里管理自己上传的图书和借阅记录。

这个业务边界决定了系统的核心功能不是"藏书的录入编辑",而是"用户与图书之间的流转关系"。所以我在设计时给系统定了三条主线:图书的生命周期(草稿->待审核->已上架->借出中->已归还/下架)、用户的借阅闭环(申请->通过->借出->归还/逾期)、以及管理员的内容管控(审核、下架、禁用用户)。所有表结构、接口设计、前端页面都围绕这三条主线展开,而不是堆砌一堆用不上的管理功能。

1.2 功能模块拆解:用户端与管理端

整个系统拆成两个端来开发,后端是同一套Springboot服务,只是按角色做了权限区分。

用户端的功能清单如下:

  • 注册与登录:用户名密码注册,密码用BCrypt加密,登录成功后返回JWT token
  • 图书浏览:首页轮播推荐、图书列表分页、按分类筛选、关键字搜索
  • 图书详情:图书信息展示、在线试读(PDF预览)、评论列表、收藏/取消收藏、借阅按钮
  • 借阅管理:提交借阅申请、查看我的借阅列表、归还操作、逾期标记
  • 个人中心:修改个人信息、我上传的图书管理(新增/编辑/下架)、我的收藏、我的评论

管理端功能清单:

  • 图书审核:对待审核图书进行通过/驳回操作,驳回要填原因
  • 用户管理:查看注册用户列表、启用/禁用账号
  • 借阅管理:查看所有借阅记录,处理借阅申请(同意/拒绝),标记归还
  • 分类管理:增删改图书分类,级联刷新前端分类导航

注意这里"权限"不是用Spring Security那种全局过滤器实现的,而是通过JWT拦截器加角色判断。管理员接口在Controller层用@PreAuthorize或者直接在拦截器校验角色都可以。我选了在拦截器里统一判断,逻辑更直观,后面会细说。

2. 技术选型复盘:Spring Boot 2.7 + Vue 3 的取舍依据

2.1 为什么坚持前后端分离

图书分享系统这种项目,单体应用不用前后端分离也能做,用Thymeleaf套页面+Ajax反而省事。但我还是选了Springboot提供纯后端API、Vue3构建SPA的分离方案。主要考虑有三点:

第一,这个系统的核心交互是异步的:借阅状态变化、评论实时显示、个人中心数据更新,SPA的局部刷新体验比服务端渲染好很多。第二,前后端分离意味着两边可以并行开发——我习惯先定义好接口文档,前端用Mock数据跑着,后端同时写接口,最后联调时把Mock地址换掉就行,整体开发周期能压缩不少。第三,部署上前后端可以独立扩展,前端构建成纯静态文件放Nginx,后端jar包单独跑,哪个出问题都不影响另一个重启。

2.2 版本选择的坑:JDK8、Spring Boot2.7、Node16

版本选择上我踩过很实在的坑。一开始图新鲜用了Spring Boot 3.0,结果它强制要求JDK17,而服务器上装的是JDK8,项目启动直接报UnsupportedClassVersionError。后来退回到Spring Boot 2.7.18,这个版本兼容JDK8,生态也成熟,相关教程、依赖兼容性都更好。

前端用了Vue3.2 + Vite4 + Element Plus + Pinia。Vue3的Composition API写起来比Options API更顺手,而且Vite的冷启动速度非常快,开发时改代码页面几乎秒刷新。但Vite要求Node版本不低于14.18,我本机是Node16,没问题;如果你用旧电脑装的Node12,npm run dev会直接报错。这里建议统一用Node16或更高版本,同时npm install的时候注意锁定一下依赖版本,Vite3和Vite4的插件写法略有不同,避免后面麻烦。

技术栈整体列表如下:

位置技术版本
后端框架Spring Boot2.7.18
持久层MyBatis-Plus3.5.3.1
数据库MySQL8.0
鉴权JWT (jjwt)0.9.1
前端框架Vue3.2.47
构建工具Vite4.3
UI组件Element Plus2.3
状态管理Pinia2.0
HTTP客户端Axios1.3

2.3 统一接口规范:Result封装和分页参数

前后端分离最怕各写各的,接口格式对不上。我提前约定了一套统一返回体,后端所有接口都返回Result结构:

{ "code": 200, "message": "操作成功", "data": {} }

code只约定几个全局值:200成功、401未登录、403无权限、500服务器异常。前端axios拦截器拿到code非200就全局弹出消息,省得每个页面单独写错误处理。

分页接口统一接收pageNum和pageSize两个参数,返回格式固定为:

{ "records": [], "total": 100, "current": 1, "size": 10 }

这个格式直接对应MyBatis-Plus的IPage,前端Element Plus的Pagination组件也能无缝对接。提前定好这套规范,后面前后端联调几乎没有因为字段名不一致扯过皮。

3. 数据库建模:用一张借阅表串起整个业务状态机

3.1 五张核心表的结构设计

图书分享系统的数据量不会很大,但表关系比较典型。我设计了两类基础表和两张业务表:

用户表(user)

  • id, username, password, nickname, avatar, role(0用户/1管理员), status(0禁用/1正常), create_time

密码字段存的是BCrypt加密后的哈希值,不存明文。role和status用tinyint,方便扩展。

分类表(category)

  • id, name, sort, create_time

图书表(book)

  • id, title, author, isbn, publisher, category_id, user_id(上传者), cover_image, file_path, description, status(0待审核/1已上架/2借出中/3已下架/4审核驳回), view_count, create_time

这里status字段承担了"内容状态"和"库存状态"双重角色。待审核和审核驳回是内容审核维度,已上架和借出中是流通维度,已下架是运营维度。实际运行下来状态有些多,但通过状态机的有限集合控制流转,代码写起来反而清晰。

借阅表(borrow)

  • id, book_id, user_id, apply_time, borrow_time, due_time, return_time, status(0待处理/1借阅中/2已归还/3逾期/4已拒绝/5已取消), extend_count

借阅表是整个业务的状态机核心,后面单独说。

评论表和收藏表

  • comment: id, book_id, user_id, content, create_time
  • favorite: id, book_id, user_id, create_time

评论和收藏都是一对多关系,一本书有多个评论,一个用户能收藏多本书,所以设计成独立的关联表。

3.2 借阅流程中的状态流转与并发控制

借阅是系统里最容易出bug的环节。简单来说,流程是:

  1. 用户在图书详情页点击"借阅"
  2. 后端检查图书状态:必须是已上架,且当前没有未终止的借阅记录
  3. 插入一条borrow记录,status=0待处理,同时把book.status改成2借出中
  4. 管理员在后台看到待处理申请,点击同意,borrow.status=1借阅中,设置due_time为当前时间加30天
  5. 借阅到期前用户可以归还;到期未归还,系统定时任务把borrow.status改成3逾期,同时book.status恢复为1已上架(或者标记可重新借出)

这里有个关键问题:如果两个用户同时点击借阅同一本书,怎么保证不被抢?我用的是乐观锁思路。

在更新图书状态时加上条件判断:

UPDATE book SET status = 2 WHERE id = #{bookId} AND status = 1

这个update如果返回的影响行数是0,说明图书已经不是"已上架"状态,直接抛出"图书已被借出"的异常。加上@Transactional确保borrow插入和book更新要么都成功,要么都回滚。

借阅表本身的status不需要唯一约束,因为同一个人可以申请同一本书的多次借阅(还了再借),但同一本书同一时刻只能有一个有效的借阅记录。由于book.status在借出时已经变为2,所以天然限制住了并发。

3.3 索引与查询优化:必须给外键加索引

数据量小的时候谈索引感觉没必要,但真到写SQL的时候你会发现没有索引的联表查询在几百条数据时也能明显卡顿。我当时遇到的问题是图书列表页按分类查询,每次都要JOIN分类表和用户表,加上模糊搜标题,查询时间从20ms涨到100多ms。排查后发现book表的category_id和user_id都没有索引,MySQL在JOIN时走了全表扫描。

给book表的category_id、user_id、status、create_time加了普通索引,borrow表的book_id、user_id、status也各自加了索引,查询时间立刻降到10ms以下。另外,图书列表页的排序用的是create_time倒序,所以给create_time加索引还能优化排序。

模糊搜索用LIKE '%关键字%'是逃不掉的,这种写法无法走索引,数据量大了会慢。但图书分享系统的数据量一般几百上千条,全表扫描也就毫秒级,不需要上ES。真要优化,可以用MySQL的全文索引或者前缀索引,但个人项目里没必要,这个取舍心里有数就行。

4. 后端实现细节:JWT鉴权、文件上传、事务边界

4.1 手写JWT拦截器而不引入Spring Security的理由

Spring Security功能强大,但配置复杂,尤其是SecurityFilterChain的写法、密码加密器配置、权限表达式,对一个小项目来说学习成本太高。图书分享系统只有用户和管理员两种角色,我需要做的只有三件事:登录时发token、后续请求校验token、管理员接口校验角色。

所以我直接用jjwt库生成和解析token,写了一个HandlerInterceptor:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { try { Claims claims = JwtUtil.parse(token.substring(7)); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { // 解析失败,继续往下走到登录判断 } } if (handler instanceof HandlerMethod) { // 检查是否跳过验证 Method method = ((HandlerMethod) handler).getMethod(); if (method.isAnnotationPresent(PassToken.class)) { return true; } } response.setStatus(401); return false; } }

再通过WebMvcConfigurer注册拦截器,排除登录、注册、图书列表、详情等白名单路径:

registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns("/user/login", "/user/register", "/book/list", "/book/detail", "/category/tree");

管理员角色判断更粗暴,写一个@RequireAdmin注解标记在需要管理员权限的接口上,拦截器里通过反射检查注解,如果当前用户role不是1就返回403。这套组合拳下来,功能完全够用,代码量比Spring Security少一大截。

4.2 图书文件上传:类型校验、大小限制与存储路径

图书分享系统涉及两种文件:封面图片和PDF电子书。Spring Boot默认上传大小限制是1MB,PDF动辄几十MB,所以必须在配置里放开:

spring: servlet: multipart: max-file-size: 100MB max-request-size: 110MB

上传接口接收MultipartFile,不能只靠前端限制类型,后端必须二次校验。我写了一个FileUtils工具类,判断扩展名和Magic Number:

  • 图片:只允许jpg、png、webp,大小不超过5MB,用ImageIO读取并校验能否解码
  • PDF:只允许pdf,大小不超过100MB,读取文件头判断是否以%PDF开头

存储路径采用"属性配置+日期分目录"的方式,例如:

uploadPath=/data/bookshare/ 最终路径:/data/bookshare/cover/2024/05/20240517123000_abc.jpg

数据库中存相对路径,如/cover/2024/05/xxx.jpg,前端访问时由后端提供静态资源映射:

WebMvcConfigurer.addResourceHandlers(registry) .addResourceHandler("/files/**") .addResourceLocation("file:" + uploadPath);

这样上传和访问都有统一入口。生产环境用Nginx映射这个目录,减轻后端压力,后面部署部分会说。

4.3 MyBatis-Plus条件构造器实现动态查询

图书列表是系统查询最多的接口,同时也最容易被做成多个参数拼接SQL的烂摊子。用MyBatis-Plus的QueryWrapper可以优雅解决:

LambdaQueryWrapper<Book> wrapper = Wrappers.lambdaQuery(); wrapper.eq(StringUtils.hasText(keyword), Book::getTitle, keyword) .eq(categoryId != null, Book::getCategoryId, categoryId) .eq(status != null, Book::getStatus, status) .orderByDesc(Book::getCreateTime); Page<Book> page = bookMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);

eq方法的第一个condition参数是boolean,只在条件为true时追加这个条件,完美避免手动字符串拼接。同时注意,如果要按"书名或作者"搜索,可以用wrapper.and(w -> w.like(Book::getTitle, keyword).or().like(Book::getAuthor, keyword))。

图书列表还需要带上上传者昵称和分类名称,有两种做法:一种是Bean拷贝加VO封装,另一种是直接在SQL里LEFT JOIN。我的做法是定义一个BookVO,自定义Mapper XML写联表查询,因为联表查询在三种表结构下非常稳定,结果直接映射到VO,不用循环去组装。

4.4 借阅操作的事务与乐观锁实现

借阅接口的核心代码如下:

@Transactional public void applyBorrow(Long bookId, Long userId) { Book book = bookMapper.selectById(bookId); if (book == null || book.getStatus() != 1) { throw new BusinessException("图书不存在或暂不能借阅"); } int rows = bookMapper.updateStatusWithCondition(bookId, 1, 2); if (rows == 0) { throw new BusinessException("图书已被借出"); } Borrow borrow = new Borrow(); borrow.setBookId(bookId); borrow.setUserId(userId); borrow.setStatus(0); borrow.setApplyTime(new Date()); borrowMapper.insert(borrow); }

关键在updateStatusWithCondition的SQL:

UPDATE book SET status = 2 WHERE id = #{bookId} AND status = 1

如果两条请求同时执行更新,MySQL的行锁会让后一个事务等到前一个提交,然后发现status已经不是1,影响行数为0,抛异常回滚。这样利用数据库行锁天然实现了乐观锁,没有引入额外机制。

归还操作类似,要从borrow表里找到status为1的记录,更新为2,同时释放book.status为1。归还时还要检查是否逾期:如果当前时间大于due_time,则把borrow.status更新为3,同时book.status更新为1。逾期本身不限制用户再借,只是留下记录,运营上方便看到。

5. 前端Vue落地实践:路由守卫、状态共享、组件复用

5.1 Vue Router的路由表设计与登录守卫

前端页面结构大概有十来个路由,我按模块分成三组:公开页面(首页、图书列表、图书详情)、需要登录的功能(个人中心、我的借阅、上传图书、收藏列表)、管理员页面(审核、用户管理、借阅管理、分类管理)。

路由表里通过meta标记是否需要登录和权限角色:

{ path: '/admin/book/audit', name: 'BookAudit', component: () => import('@/views/admin/BookAudit.vue'), meta: { requiresAuth: true, role: 'admin' } }

全局前置守卫:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); } else if (to.meta.role === 'admin' && store.user?.role !== 1) { next({ path: '/403' }); } else { next(); } });

注意route的meta信息在router.beforeEach里是可以直接读取的。我一开始用v-if手动判断登录状态,结果页面刷新后状态丢失,跳转逻辑一团乱,后来才统一交还给路由守卫管理,体验正常了。

5.2 Pinia如何管理登录态和全局数据

Vuex在Vue3里写起来比较啰嗦,Pinia更像是为Composition API量身定做的。我建了一个user store:

import { defineStore } from 'pinia'; export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token'), userInfo: JSON.parse(localStorage.getItem('userInfo') || '{}') }), actions: { login(data) { this.token = data.token; this.userInfo = data.userInfo; localStorage.setItem('token', this.token); localStorage.setItem('userInfo', JSON.stringify(this.userInfo)); }, logout() { this.token = ''; this.userInfo = {}; localStorage.removeItem('token'); localStorage.removeItem('userInfo'); } } });

登录成功后把token和userInfo双写一份到localStorage,防止刷新后store清空。个人中心修改昵称或头像后,直接调用store.updateUserInfo并发一个请求后端更新,保证全局引用同步。

5.3 可复用的图书卡片与上传组件

图书列表页和首页都展示图书卡片,我抽了一个BookCard.vue组件,接收book对象,内部展示封面、标题、作者、借阅状态,点击跳转详情。组件内部用computed根据book.status映射状态文案:1显示可借,2显示已借出,0显示审核中,3显示已下架。

上传组件没那么好抽,但封面和PDF的image-upload逻辑可以共用。我封装了一个FileUpload.vue,基于Element Plus的el-upload二次封装,props传action、fileType、maxSize,内部做文件类型和大小校验,上传成功后把返回的URL传入父组件v-model。这样在发布图书和修改个人信息头像时都能复用同一套上传逻辑。

5.4 Axios请求封装与401处理

所有请求统一走request.js:

const request = axios.create({ baseURL: '/api', timeout: 10000 }); 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) { ElMessage.error(res.message); return Promise.reject(res); } return res; }, error => { if (error.response?.status === 401) { ElMessage.error('登录已过期,请重新登录'); localStorage.clear(); router.push('/login'); } else { ElMessage.error('服务器开小差了'); } return Promise.reject(error); } );

这里有个容易踩的坑:响应拦截器的成功分支里如果也返回res,那么所有接口拿到的都是res而不是response,如果某个接口需要拿HttpStatus或response headers就会失效。所以我要么全部走error分支处理业务码,要么统一返回res.data,根据自己的规范来。我选择了后者,接口层完全感知不到HTTP状态码,统一都是Result对象。

6. 部署与排障:从本地联调到服务器上线

6.1 开发环境的代理配置与生产环境的Nginx转发

前后端分离开发阶段最大的问题是跨域。后端在8080端口,前端Vite在5173端口,直接请求必定跨域。我的做法是在vite.config.js里配置代理:

server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }

这样前端所有请求都写/api/xxx,开发时Vite会代理到后端,本地不触发浏览器跨域。生产环境把dist部署到Nginx,同样配置一个/api反向代理到后端进程:

location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /files/ { alias /data/bookshare/; }

注意proxy_pass后面的斜杠非常关键,写http://127.0.0.1:8080/会把请求路径中的/api前缀去掉;如果不带斜杠,则会把/api也转发到后端,后端Controller路由就要带/api前缀。我在这一块由于加了斜杠,后端Controller没带前缀,反而正好匹配,这里要根据自己后端路由前缀设计联调一下。

6.2 jar包与dist静态资源的部署步骤

部署阶段我整理了一个标准操作流程,照着执行几乎不会出错:

  1. 后端执行mvn clean package -DskipTests,在target下生成bookshare.jar
  2. 前端执行npm run build,生成dist目录
  3. 服务器创建项目目录,上传jar包和dist,解压dist到/data/www/bookshare
  4. 复制一份application-prod.yml,修改MySQL连接、上传路径等生产配置
  5. 启动后端:nohup java -jar bookshare.jar --spring.profiles.active=prod > /data/logs/bookshare.log 2>&1 &
  6. Nginx配置上面提到的/api代理和dist静态文件指向
  7. 用curl -I http://localhost:8080/api/book/list验证后端连通,再访问服务器IP验证前端

我没有用Docker部署,因为项目就一个jar加一个dist,用Docker反而增加镜像构建和网络配置的成本。如果你们那边服务器资源紧张,建议直接用systemd管理后端进程,比nohup更稳。

6.3 真实踩坑记录:5个典型问题及修复过程

这里整理几个开发时真正耗过我半天时间的问题,希望能帮你跳过。

问题1:Spring Boot 3.0启动直接报错。原因是我本机JDK8,Spring Boot 3要求JDK17。解决方法是回退到2.7.18,同时确认Maven的compiler属性是1.8,否则Lombok注解不生效。这类版本错位问题,第一次遇到时很崩溃,因为报错堆栈看起来像环境问题。

问题2:上传PDF超过1MB就报MaxUploadSizeExceededException。排查了很久才发现是Spring Boot默认限制,必须在application.yml里设置multipart.max-file-size和max-request-size,同时如果前端有Nginx,还要确保client_max_body_size配置了足够大小,比如设置为100m。三者缺一不可,不然前端上传还是会被Nginx拦下。

问题3:Vite开发时代理没问题,打包后登录接口404。原因是我在Nginx的/api反向代理路径上加不加斜杠导致后端没接收到正确请求。后来统一约定前端请求统一带/api前缀,后端Controller层路由保留/book/list,Nginx把/api前缀吃掉;改完Nginx配置后重启,一切正常。这提醒我部署时要多看一遍proxy_pass的路径拼写,别想当然。

问题4:路由守卫死循环。登录成功后跳转到redirect参数指定的地址,如果redirect是/login本身就会循环。我在守卫里加了一层判断,当to.path是/login时直接next(),不重定向。

问题5:图片上传成功但不显示。数据库存的是/cover/xxx.jpg,页面里img的src是http://localhost:8080/files/cover/xxx.jpg,后端静态资源映射配置没问题,但Nginx里没有代理/files路径。加上后图片正常。这个问题的本质是开发环境端口与生产环境域名不一致,后端返回的相对路径要想好,前端统一拼接静态资源基础路径。

做完这个项目之后我最大的体会是,图书分享系统这种中型前后端分离项目,真正的难点不是某个单一技术,而是状态转换、文件存取、接口约定这些"夹缝"里的细节。如果你正在写类似系统,建议先把借阅状态机画清楚,把所有状态流转画在纸上,再写代码。同时把前端的代理和后端的文件目录映射提前规划好,省下的调试时间会非常可观。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询