SpringBoot+Vue图书管理系统从零搭建:数据库设计、借阅事务与部署实战

发布时间:2026/10/8 10:14:56
SpringBoot+Vue图书管理系统从零搭建:数据库设计、借阅事务与部署实战 做图书管理系统这个题目的人我见过太多了十有八九都是拿到SpringBootVue这套技术栈就开写结果做到借阅模块就开始卡壳要么库存对不上要么前端接口调不通最后一遍遍查日志改代码时间全耗在环境问题和解bug上。这篇我就按自己当初完整做完整个项目的思路来复盘一遍从为什么要这么选型到数据库表怎么设计再到借阅归还这种核心业务到底该怎么写事务最后把部署和常见坑一次讲完。内容全部围绕SpringBootVueMySQLMyBatis这套组合展开源码结构和关键代码都会给到适合正在做毕设、课设或者想自己从零搭建一个前后端分离管理系统的读者参考。我默认你的环境情况是JDK 8或11Maven 3.6以上MySQL 5.7或8.0Node.js 14以上。这几个版本组合是目前兼容性最稳的后面章节我会专门讲版本不匹配会出哪些幺蛾子。1. 为什么是这套组合选型思路和适用场景先别急着写代码想清楚“为什么用这套技术栈”比什么都重要。图书管理系统从功能上看就是个典型的中小型CRUD项目外加借阅、归还、统计这类带状态流转的业务。这种项目用SpringBootVueMySQLMyBatis说白了就是三个字够用、好招、有东西可写。1.1 技术栈优劣势分析自主选型阶段我把常见的几种方案都过了一遍列个对比表你感受下方案开发效率学习成本面试/答辩亮点适用场景JSP Servlet JDBC低大量重复代码低几乎没有课程设计老题目SpringMVC JSP中渲染逻辑耦合中一般前后端不分离SpringBoot Thymeleaf高但前后端分离度不足中一般小型单体演示SpringBoot Vue前后端分离高中高强符合主流管理系统通用方案若依/RuoYi等脚手架极高中较弱非原创快速交付SpringBoot的价值在于自动配置和内嵌容器。你不用再折腾Tomcat、Spring配置文件一个Application类跑起来就完事。Vue的价值在于组件化图书列表、借阅表单、统计图表各自成组件改一处不影响全局。MyBatis的价值在于SQL可控像图书多条件组合查询这种场景写一条动态SQL比用JPA拼方法名清楚一百倍。1.2 这个组合适合什么样的项目以图书管理系统为例它要处理的业务并不复杂图书信息维护、读者信息维护、借书、还书、查询记录。但正因为业务简单反而适合用来完整走一遍“设计数据库-写后端接口-写前端页面-联调-部署”的全流程。这也是我推荐用它练手的原因——业务复杂度低的时候你才有精力关注架构、事务、权限这些真正值钱的东西。如果你现在的题目是图书管理之外的别的管理系统这套组合同样适用后面我讲的设计思路都是通用的只是把“图书”换成你的业务实体而已。2. 数据库设计几张表、几个关键坑数据库是这整个系统里最不应该马虎的部分。借阅记录、图书库存、读者借阅上限这些业务规则看似简单表结构设计得不好后面写代码会处处别扭。2.1 核心表结构和字段说明我的做法是四张核心表图书表、读者表和登录用户表合一、分类表、借阅记录表。如果你要加管理员角色可以再拆一张角色表一般课设做到这四张已经够了。图书表CREATE TABLE book ( id int NOT NULL AUTO_INCREMENT COMMENT 主键, isbn varchar(32) DEFAULT NULL COMMENT ISBN号可空, book_name varchar(128) NOT NULL COMMENT 书名, author varchar(64) DEFAULT NULL COMMENT 作者, publisher varchar(64) DEFAULT NULL COMMENT 出版社, category_id int DEFAULT NULL COMMENT 分类ID关联category表, total_stock int NOT NULL DEFAULT 0 COMMENT 总库存, borrowed_count int NOT NULL DEFAULT 0 COMMENT 当前已借出数量, cover_image varchar(255) DEFAULT NULL COMMENT 封面图URL, description text COMMENT 简介, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_book_name (book_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个值得关注的点。第一库存字段。我见过很多人只放一个stock字段借书就减1还书就加1。看起来没问题但你无法回答“一本书总共采购了几本、被借出去几本、现在还剩下几本”这个问题。所以我把总量和借出量分开可借数量通过total_stock - borrowed_count实时计算。这样做统计报表的时候非常省事。第二书名索引。图书查询是高频操作尤其是用书名做模糊搜索。这里用普通索引加模糊查询量小无所谓但如果数据上千条你会明显感觉到LIKE %关键词%无法走索引的性能问题。作为管理系统可以用LIKE 关键词%的格式配合前缀索引体验会好一些。借阅记录表CREATE TABLE borrow_record ( id int NOT NULL AUTO_INCREMENT, book_id int NOT NULL COMMENT 图书ID, reader_id int NOT NULL COMMENT 读者ID, borrow_time datetime NOT NULL COMMENT 借出时间, due_time datetime NOT NULL COMMENT 应还时间, return_time datetime DEFAULT NULL COMMENT 实际归还时间, status tinyint NOT NULL DEFAULT 0 COMMENT 0-借出中 1-已归还 2-逾期未还, operator varchar(32) DEFAULT NULL COMMENT 操作人, PRIMARY KEY (id), KEY idx_reader (reader_id), KEY idx_book (book_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么借阅记录要单独成表而不是在图书表里塞一个“当前借阅人”字段很简单一本书会被反复借阅每一次借阅都是一条独立的历史记录。如果你只在图书表里记录当前借阅人那“这个读者上个月借过什么书”这种基础问题你都答不上来。所以凡是涉及历史追溯的数据必须安排独立的明细表。2.2 常见的数据库设计失误我帮人改过好几个图书管理项目最经典的问题有三个。第一个外键约束加了一堆。InnoDB支持外键但实际开发里不管是性能还是Delete的灵活性外键都容易变成麻烦。我的建议是逻辑外键也就是在实体设计上知道哪个字段对应哪张表但数据库层面不建物理外键约束由Service层保证数据一致性。第二个时间字段用varchar而不是datetime。有人图省事前端传什么字符串就存什么结果后面要做“最近7天借阅统计”这种查询时一堆字符串没法直接比较。日期就该用datetime或date类型这是原则问题。第三个全文不分页。图书管理系统要想好用分页是必须的。数据库层面的技巧是用LIMIT offset, size做物理分页配合排序字段不要一查就全量返回几千条。3. 后端实现从接口规范到借阅事务后端是整个项目的大脑这部分我会把接口设计、MyBatis用法、借阅业务的事务处理拆开讲。这部分也是答辩时老师最爱深挖的地方值得认真过一遍。3.1 RESTful接口与统一返回结构常规的CRUD接口人人都能写但规范不统一会坑到前后端联调。我定了一套统一的返回结构所有接口都返回这个对象public class ResultT implements Serializable { private Integer code; // 状态码200成功500业务异常401未登录 private String message; // 提示信息 private T data; // 泛型数据 // 提供 success(), error() 等静态工厂方法 }接口规划方面按照资源来划分/api/book、/api/reader、/api/borrow。每个资源下用HTTP Method区分动作GET做查询POST做新增PUT做修改DELETE做删除。借书和还书不是标准的CRUD我用POST/api/borrow和PUT/api/borrow/{id}/return表达。3.2 借书和还书的核心事务逻辑借书功能看起来简单插入一条借阅记录然后把图书表的borrowed_count加1。但这两步操作如果不放在一个事务里会出现“记录插入成功但库存没扣”或者“库存扣了但记录没插上”这种数据不一致。解决办法就是Transactional。Service public class BorrowService { Resource private BookMapper bookMapper; Resource private BorrowRecordMapper borrowRecordMapper; Transactional(rollbackFor Exception.class) public void borrowBook(Integer bookId, Integer readerId) { // 1. 查询图书校验库存 Book book bookMapper.selectById(bookId); if (book null) { throw new BusinessException(图书不存在); } int available book.getTotalStock() - book.getBorrowedCount(); if (available 0) { throw new BusinessException(库存不足); } // 2. 更新借用数量 int updateCount bookMapper.increaseBorrowedCount(bookId); if (updateCount 0) { throw new BusinessException(借阅失败请重试); } // 3. 插入借阅记录默认借期30天 BorrowRecord record new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowTime(new Date()); record.setDueTime(DateUtil.addDays(new Date(), 30)); record.setStatus(0); borrowRecordMapper.insert(record); } }这里最容易被忽略的是increaseBorrowedCount这条SQL它是整个并发控制的关键。update idincreaseBorrowedCount UPDATE book SET borrowed_count borrowed_count 1 WHERE id #{bookId} AND total_stock - borrowed_count 0 /update这条SQL把“判断库存是否充足”和“扣库存”合并成了一步。数据库在更新时会锁住这行记录两个用户同时借同一本书时第二个用户的事务会阻塞等待等第一个提交后total_stock - borrowed_count 0这个条件就不满足了更新影响行数为0代码抛出“借阅失败”。这就避免了超卖问题。如果用先查库存再更新的代码逻辑在并发场景下会出大问题这也是面向前端展示的“可借数量”到了实际借阅时为什么还要二次校验的原因。还书的时候反向操作更新borrowed_count减1同时把借阅记录的status改为1填入return_time。同样需要事务Transactional(rollbackFor Exception.class) public void returnBook(Integer recordId) { BorrowRecord record borrowRecordMapper.selectById(recordId); if (record null || record.getStatus() ! 0) { throw new BusinessException(借阅记录不存在或已归还); } borrowRecordMapper.returnRecord(recordId, new Date()); bookMapper.decreaseBorrowedCount(record.getBookId()); }3.3 MyBatis动态SQL的实际使用场景图书列表查询是最典型的动态SQL场景。用户按书名查、按分类查、按作者查这些条件组合在一起如果写死SQL就得准备很多个版本。MyBatis的where和if标签能解决select idselectByCondition resultTypecom.example.entity.Book SELECT * FROM book where if testbookName ! null and bookName ! AND book_name LIKE CONCAT(%, #{bookName}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if if testauthor ! null and author ! AND author LIKE CONCAT(%, #{author}, %) /if /where ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select这里多说一句LIKE CONCAT(%, #{bookName}, %)别直接在代码里写%#{bookName}%这个是语法错误因为#{}不能出现在字符串字面量里。类似这种细节实战里写的多了自然有记忆。分页这里MyBatis原生做法是用LIMIT offset, pageSize此时需要手动在Mapper接口里传offset和pageSize两个参数。如果你用PageHelper则无需手动计算PageHelper.startPage(pageNum, pageSize); ListBook bookList bookMapper.selectByCondition(query); PageInfoBook pageInfo new PageInfo(bookList);PageHelper的原理是拦截下一次要执行的SQL自动拼接LIMIT语句所以PageHelper.startPage()必须紧跟要分页的查询语句中间不能有其他SQL执行否则分页会串到别的查询上去。这个坑实测踩的人非常多我给你打上星标。4. Vue前端页面规划、路由与权限控制后端接口写完前端就是把这套数据用看得见的方式呈现出来。我用的技术栈是Vue 2 Element UI Axios Vue Router这套组合文档多、报错搜得到答案做课设最稳。如果你用Vue 3对应的是Element Plus逻辑基本一致。4.1 页面模块拆解和路由设计图书管理系统的前端页面我拆成四个主模块图书管理图书列表、新增/编辑图书、图书分类管理借阅管理借书操作、在借列表、归还处理、历史记录读者管理读者列表、新增/编辑读者、查看借阅历史系统管理登录、修改密码、管理员信息路由设计上最简单的做法是静态路由所有页面都在路由表里登录后根据角色做菜单显隐控制。如果你的项目需要更完整的权限控制可以做成动态路由登录成功后后端返回该用户的角色和菜单权限前端用router.addRoutes()动态注册。作为课设静态路由加按钮级权限控制已经足够答辩时能讲清原理就好。// 路由守卫示例未登录跳转登录页 router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.path ! /login !token) { next(/login); } else { next(); } });4.2 Axios拦截器与登录状态管理前后端分离以后登录状态通常用JWT维护。前端在用户登录后拿到token存到localStorage里每次请求在Axios拦截器里带上service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code 401) { localStorage.removeItem(token); router.push(/login); } return res; }, error { // 统一错误提示比如网络异常 return Promise.reject(error); } );这样做的好处是以后登录过期、token失效前端所有接口自动获得统一的处理逻辑不用在每个页面里写重复的失败判断。4.3 图书借阅页面的核心交互借阅页面是前端交互最复杂的部分。需要做三件事搜索图书、查看当前库存状态、提交借阅。库存状态我用计算属性实时算出来available totalStock - borrowedCount当available 0时按钮置灰并显示“可借0本”。注意这里的“可借数量”是列表返回的静态数据实际能不能借成功还是要靠后端接口的二次校验前端只是提升体验不能作为业务依据。借阅成功后当前行的可借数要立即刷新。我一开始的做法是重新刷新整个表格后来发现用户操作后表格跳到第一页体验很生硬。改成删除当前行数据再重新调用一次列表查询或者更新局部状态效果会好很多。5. 实际开发遇到的坑和解决过程这一章节是我最想分享的内容。你可能规划得再好一跑起来全是问题。我按实际踩坑的时间顺序把影响最大、最容易复发的几个坑写在这里。5.1 跨域问题的三种解法前后端分离项目前端跑在8080端口后端跑在8081端口浏览器默认拒绝跨域请求。我试过三种方式最终用了Nginx代理方案但本地开发阶段用的是第三种。第一种在SpringBoot里加CrossOrigin或全局CORS配置。开发很方便但生产环境有安全隐患不建议依赖这种方式。第二种前端Vue配置代理转发// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } };这样前端页面里请求/api/book开发服务器会转发到http://localhost:8081/api/book绕过浏览器跨域限制。第三种生产环境用Nginx做反向代理把同一个端口下的不同路径转发到前后端服务。这种方式后端代码零改动也是实际部署推荐的方式。5.2 MyBatis驼峰映射和字段为null的问题数据库字段一般是下划线风格book_name、create_time。Java实体类习惯是驼峰风格bookName、createTime。如果不做配置查询结果里这些字段全是null。解决办法是在application.yml里开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true当然也可以给每一条查询写AS别名但几十个字段全写别名累且容易漏。一条配置全解决何乐不为。5.3 PageHelper适配和total始终为0这个坑的具体表现是列表数据能正常显示但分页组件显示的total永远是0或者总数不对。原因通常是PageHelper的依赖版本和SpringBoot版本不兼容或者分页插件没有生效。我的排查链路是这样的先确认依赖坐标是否正确我用的是pagehelper-spring-boot-starter注意别用成mybatis-pagehelper。再确认使用的版本和MyBatis版本兼容PageHelper 1.4.x系列适配SpringBoot 2.x。最后确认启动类有没有配置拦截器组件用starter方式会通过Configuration自动注册如果手动配了MyBatis则可能冲突。5.4 LocalDateTime传到前端变成时间戳Java 8的LocalDateTime如果不做处理默认序列化成数组或者时间戳前端拿到手根本不是预想的2024-05-20 12:00:00。解决办法是在SpringBoot里配置Jackson的日期格式Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder - { builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss); builder.serializers(new LocalDateTimeSerializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializers(new LocalDateTimeDeserializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; } }这个问题我在写借阅记录列表时遇到还书时间显示成一大串数字看着很懵。配置好之后世界清净了。5.5 MySQL 5.7版本时间字段默认值不生效MySQL 5.7里DATETIME类型直接写DEFAULT CURRENT_TIMESTAMP是可以的但如果表创建顺序里有ON UPDATE CURRENT_TIMESTAMP在某些版本上会出现“语法没问题但update时不自动刷新”的情况。我实际遇到后换了个思路应用层每次更新都显式设置update_time不依赖数据库自动更新。这样行为可预期也不会出兼容问题。6. 部署运行与源码结构注意事项最后一部分讲怎么把项目跑起来以及源码结构怎么组织。这部分对参考源码的人来说是刚需。6.1 本地运行完整步骤后端安装MySQL建议5.7或8.0新建数据库library_system字符集选utf8mb4。把项目里的library_system.sql脚本导入数据库。如果源码没有SQL脚本用我前面给的建表语句手工执行也行。修改application.yml里的数据库账号、密码、端口。注意检查url参数加useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8。用Maven执行mvn clean package或者IDE里直接运行Application主类。浏览器访问http://localhost:8081能看到接口返回说明环境OK。前端在vue-admin目录下执行npm install网络慢就配置淘宝镜像。执行npm run serve默认端口是8080。浏览器访问http://localhost:8080看到登录页代表前后端已连通。6.2 后端源码结构怎么组织包结构是我比较满意的部分直接展示给你参考com.example.library ├── common // 通用类Result返回体、全局异常、常量 ├── config // 配置类CORS、Jackson、Kaptcha验证码 ├── controller // 控制器层接收请求、参数校验 ├── service // 业务逻辑层核心事务、业务规则 ├── mapper // MyBatis Mapper接口 ├── entity // 实体类对应数据库表 ├── dto // 传输对象接收前端查询条件之类 └── utils // 工具类时间处理、JWT工具我见过很多毕业设计的源码几百行代码堆在两三个类里看着头大。分层虽然会多写几个类但后续维护和答辩讲代码时你能很清晰地告诉别人“这个类负责什么”。这种结构化思维是个加分项平时写代码就该养成。6.3 部署到服务器的可选方案如果你需要把项目部署展示给老师看两个方案。方案一打jar包直接跑。后端mvn clean package后得到jar文件服务器上安装JDK执行nohup java -jar library-system.jar 。方案二前后端分离部署。前端npm run build生成dist目录交给Nginx托管后端jar包配好端口后启动Nginx里把/api请求转发到后端端口。写一个Nginx配置示例server { listen 80; server_name your-server-ip; root /opt/library/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8081/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这里注意前端路由如果是history模式必须在location /里配置try_files否则刷新页面就404。用hash模式则不需要但URL里会带#号。我推荐刷新体验更好的history模式加try_files方案部署时这个配置必须记得写上。6.4 拿到别人的源码后怎么快速改造成自己的题目很多读者手里拿到的是别人已经写好的源码直接运行没问题但想改成自己的题目又不知道从哪下手。我的建议是按这个顺序动刀先改前端页面上的标题、logo、版权信息。全局搜索原项目名批量替换成自己的系统名。再造几张业务表。比如“图书分类”改成你自己的分类字段名不变只是数据内容换一下。最后再考虑加功能。比如原系统没有统计图表你可以加一个ECharts的借阅趋势图。加功能前先在草稿纸上画出数据库字段调整计划不要直接就往代码里塞逻辑。改完之后整个项目从内容到功能都不太一样了答辩时你能讲清楚每一处设计这比直接照搬源码能获得高得多的评价。做完这个项目之后我最大的体会是图书管理系统这种“简单业务”反而是练全栈能力的好题目因为它把CRUD、事务、权限、部署这些要素都涵盖了但又不至于复杂到让初学者失去耐心。如果你也准备用这套SpringBootVueMySQLMyBatis组合做类似系统不必追求功能堆得多把借阅库存的一致性、分页查询的流畅性、前后端联调这几个核心点吃透收获会比你想象的大得多。遇到问题别急着绕过去多看一眼日志多打断点追一遍数据流转这个过程本身比项目本身更值钱。