
上个月刚把图书分享系统做完交付从需求分析到部署上线差不多用了三周。这个项目是基于SpringbootVue的前后端分离实现核心功能包括用户注册登录、图书上传、审核、借阅、评论、收藏以及管理员后台的全流程管理。写这篇文章不是交代课设作业而是把整个设计过程中踩过的坑、做过的取舍、以及真正影响开发效率的几个关键点梳理出来给正在做类似SpringbootVue项目的人当个参考。如果是第一次接触这类系统你可能会觉得图书分享不就是做个图书列表加个借阅按钮嘛。真做起来你会发现真正的复杂度在借阅状态流转、文件上传限制、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没问题如果你用旧电脑装的Node12npm 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.3UI组件Element Plus2.3状态管理Pinia2.0HTTP客户端Axios1.32.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 五张核心表的结构设计图书分享系统的数据量不会很大但表关系比较典型。我设计了两类基础表和两张业务表用户表userid, username, password, nickname, avatar, role(0用户/1管理员), status(0禁用/1正常), create_time密码字段存的是BCrypt加密后的哈希值不存明文。role和status用tinyint方便扩展。分类表categoryid, name, sort, create_time图书表bookid, title, author, isbn, publisher, category_id, user_id(上传者), cover_image, file_path, description, status(0待审核/1已上架/2借出中/3已下架/4审核驳回), view_count, create_time这里status字段承担了内容状态和库存状态双重角色。待审核和审核驳回是内容审核维度已上架和借出中是流通维度已下架是运营维度。实际运行下来状态有些多但通过状态机的有限集合控制流转代码写起来反而清晰。借阅表borrowid, 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_timefavorite: id, book_id, user_id, create_time评论和收藏都是一对多关系一本书有多个评论一个用户能收藏多本书所以设计成独立的关联表。3.2 借阅流程中的状态流转与并发控制借阅是系统里最容易出bug的环节。简单来说流程是用户在图书详情页点击借阅后端检查图书状态必须是已上架且当前没有未终止的借阅记录插入一条borrow记录status0待处理同时把book.status改成2借出中管理员在后台看到待处理申请点击同意borrow.status1借阅中设置due_time为当前时间加30天借阅到期前用户可以归还到期未归还系统定时任务把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写了一个HandlerInterceptorpublic 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默认上传大小限制是1MBPDF动辄几十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可以优雅解决LambdaQueryWrapperBook 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); PageBook 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的SQLUPDATE 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 storeimport { 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.jsconst 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静态资源的部署步骤部署阶段我整理了一个标准操作流程照着执行几乎不会出错后端执行mvn clean package -DskipTests在target下生成bookshare.jar前端执行npm run build生成dist目录服务器创建项目目录上传jar包和dist解压dist到/data/www/bookshare复制一份application-prod.yml修改MySQL连接、上传路径等生产配置启动后端nohup java -jar bookshare.jar --spring.profiles.activeprod /data/logs/bookshare.log 21 Nginx配置上面提到的/api代理和dist静态文件指向用curl -I http://localhost:8080/api/book/list验证后端连通再访问服务器IP验证前端我没有用Docker部署因为项目就一个jar加一个dist用Docker反而增加镜像构建和网络配置的成本。如果你们那边服务器资源紧张建议直接用systemd管理后端进程比nohup更稳。6.3 真实踩坑记录5个典型问题及修复过程这里整理几个开发时真正耗过我半天时间的问题希望能帮你跳过。问题1Spring Boot 3.0启动直接报错。原因是我本机JDK8Spring 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拦下。问题3Vite开发时代理没问题打包后登录接口404。原因是我在Nginx的/api反向代理路径上加不加斜杠导致后端没接收到正确请求。后来统一约定前端请求统一带/api前缀后端Controller层路由保留/book/listNginx把/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路径。加上后图片正常。这个问题的本质是开发环境端口与生产环境域名不一致后端返回的相对路径要想好前端统一拼接静态资源基础路径。做完这个项目之后我最大的体会是图书分享系统这种中型前后端分离项目真正的难点不是某个单一技术而是状态转换、文件存取、接口约定这些夹缝里的细节。如果你正在写类似系统建议先把借阅状态机画清楚把所有状态流转画在纸上再写代码。同时把前端的代理和后端的文件目录映射提前规划好省下的调试时间会非常可观。