疫情之后,图书馆这类公共场所的运营方式发生了很大变化。很多学校、社区的图书馆不再只是“开门—借书—还书”的简单流程,而是多了预约名额、时段限流、入馆登记这类需求。我之前带开发了一套模拟项目X——基于SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0的疫情下图书馆管理系统,核心就是把传统图书管理流程和预约限流机制合到一套系统里,既保留图书管理的基础功能,又把疫情场景下的特殊运营规则落地成可用的线上功能。这篇就把这套项目的设计思路、数据库、前后端实现、部署细节完整拆一遍。适合正在做类似课设的在校生,也适合想接手图书馆管理系统改造的开发者,都能从里面找到能直接抄的配置和代码。
1. 项目整体设计与技术选型分析
1.1 为什么是 SpringBoot2 + Vue3,而不是更老或更新的组合
先解释选型。SpringBoot2 是目前存量项目里最稳的 Java 后端框架。它把繁琐的 XML 配置、容器配置全部收进自动配置里,我只需要在 pom 里引入依赖,写一个启动类,就能把 Web 服务跑起来。虽然现在 SpringBoot3 也发布了,但 Boot2 的生态资料、第三方 starter 兼容性更稳,很多学校、公司的生产环境还停在 Boot2 上。更重要的是,Boot2 配合 JDK8/11 不会出现新版本模块化带来的一堆坑,对课设和中小型项目来说,少出问题比用最新技术更值。
Vue3 相比 Vue2 最大的变化是 Composition API,逻辑可以按功能聚合而不是按选项分散。图书馆管理系统这种中后台项目,页面多、列表多、弹窗多,用 setup 写法可以把同一个业务的所有状态、事件、请求整合在一起,代码维护起来比 Options API 舒服很多。配合 Vite 的启动速度和热更新效率,开发体验提升不是一点半点。UI 层我直接用了 Element Plus,表格、表单、日期选择器、弹窗这些都是现成的,项目做出来也不会显得粗糙。
再来说前后端分离。传统单体模板渲染的方式,前端页面和后端逻辑耦合在一起,改一个按钮都要重新打包部署。疫情场景下的需求变化很频繁,比如今天要加一个时段字段,明天要调整预约名额上限,前后端分离之后,只要接口约定好,前端页面和后端服务可以独立开发、独立上线。对于团队协作和答辩演示来说,这也是一个非常清晰的架构亮点。
1.2 为什么数据层锁定 MyBatis-Plus 和 MySQL 8.0
持久层用 MyBatis-Plus 的理由很直接:常规单表 CRUD 几乎不用写 SQL。它内置了 insert、deleteById、selectById、selectPage 这些通用方法,只要实体继承 BaseMapper,大部分增删改查直接掉方法即可。项目里最常用的分页查询,配置一个 MybatisPlusInterceptor,再调用 Page 对象就能自动拼接 limit 语句,省去大量 mapper.xml。和 JPA 相比,MyBatis-Plus 更适合这种表关系复杂、需要精细控制 SQL 的业务;和原生 MyBatis 相比,又少了大量重复的 XML 配置。对我来说,这是课程设计和企业中小型项目之间最舒服的平衡点。
MySQL8.0 的吸引力则来自几个实用的特性。第一,默认字符集是 utf8mb4,读者昵称、书评里出现生僻字甚至 Emoji 都不会乱码。第二,8.0 对 JSON 字段类型支持得不错,读者扩展信息、预约附加字段都可以用 JSON 存。第三,窗口函数在统计报表场景很实用,比如计算“每个时段预约排名前几的时段”,用 ROW_NUMBER() 就能搞定。选型时我也考虑过 PostgreSQL,但它在大众运维环境里不如 MySQL 普及;用 H2 做演示型项目又太轻,后期上并发、上事务都不够稳。综合下来,MySQL8.0 是最符合“能跑、能演示、能上线、能答辩”的选择。
1.3 疫情场景下的核心需求拆解
传统图书馆管理系统只解决三件事:管书、管读者、管借还。疫情叠加之后,业务多了一层“空间和人员的管控”。我在做需求分析时没有直接抄常规图书管理系统功能,而是先列了一个特殊场景清单。
第一是预约入馆。读者不能随到随进,要先选日期、选时间段,每个时段限制人数。第二是到馆登记。入馆的时候记录体温和健康状态,生成一个预约凭证,管理员扫码或核验后放行。第三是图书流通隔离。有读者归还的书,需要先进入待处理状态,不能立刻被下一位读者借走,所以图书状态里要增加“隔离中”这一项。第四是运营公告。开放时间调整、临时闭馆、预约规则变化,这些东西要及时通知到读者端。
把这四个特殊需求映射到系统功能模块,就得出了完整的功能清单:读者端有预约入馆、图书检索、借阅记录;管理员端有图书管理、读者管理、预约时段配置、预约审核、图书状态管理、统计报表。角色至少分成两类,一类是普通读者,一类是图书管理员。如果实际使用场景复杂,还可以拆出超级管理员和值班管理员,但核心权限边界先定好,后续扩展就不会乱。需求一旦拆到这里,数据库设计和接口设计就顺理成章了。
2. 核心功能模块与数据库表设计实战
2.1 图书管理模块的表设计
图书表是我最先设计的,命名上直接叫 book。核心字段如下:id、isbn、title、author、publisher、category_id、location、total_stock、available_stock、status、create_time、update_time、deleted。其中 available_stock 是现有可借库存,total_stock 是馆藏总量。这里要特别强调,available_stock 绝对不能由前端直接修改,只能通过借书和还书两个事务来加减。否则一旦有读者并发借书,库存很容易被扣成负数。
对应的建表 SQL 可以参考这个简化版:
CREATE TABLE `book` ( `id` bigint NOT NULL AUTO_INCREMENT, `isbn` varchar(32) NOT NULL COMMENT 'ISBN编号', `title` varchar(200) NOT NULL COMMENT '书名', `author` varchar(100) DEFAULT NULL, `publisher` varchar(100) DEFAULT NULL, `category_id` bigint DEFAULT NULL, `location` varchar(50) DEFAULT NULL COMMENT '馆藏位置', `total_stock` int DEFAULT '0', `available_stock` int DEFAULT '0', `status` tinyint DEFAULT '1' COMMENT '1在馆 0下架 2隔离', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, `deleted` tinyint DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_isbn_deleted` (`isbn`, `deleted`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有一个我在实战中踩过的坑:isbn 应该唯一,但项目用了逻辑删除,历史数据不会物理删除。如果我在 isbn 上建普通唯一索引,那某本书被逻辑删除后再录入同一个 isbn,会直接插入失败。我的方案是把唯一索引改成 (isbn, deleted) 的组合索引,因为被删除的数据 deleted 变成 1,新插入的数据 deleted 为 0,组合索引下不会冲突。这个设计在逻辑删除项目里非常常见,大家可以直接照搬。
图书分类一般不超过三级,我单独建了 category 表,用 parent_id 自关联实现树形结构。前端分类树按层级懒加载展开,不一次性把整棵树塞进下拉框,否则后期分类多了页面会卡。
2.2 读者与借阅流程设计
读者表 reader 的核心字段是 id、name、phone、card_no、type、status、department、create_time。type 区分学生、教职工、普通读者,status 控制正常和停用。密码字段不能明文存储,必须用 BCrypt 加密。疫情登记需要的体温和健康状态,我没有混进 reader 主表,而是单独放到预约/入馆记录表里。这样读者主表保持干净,将来做数据清理也容易。
借阅记录表 borrow_record 是整个借还流程的核心。字段包括 id、reader_id、book_id、borrow_time、due_time、return_time、status、fine_amount。status 一般分四种:0 在借、1 已还、2 逾期、3 丢失。借书时先校验读者状态,再检查可借数量是否达到上限,然后锁住图书行,确认 available_stock 大于 0,插入借阅记录,最后扣减库存。还书时更新记录状态、计算是否逾期、按每日罚金更新 fine_amount,最后归还库存。
借还操作必须放在同一个事务里。我的写法是在 Service 方法上加 @Transactional(rollbackFor = Exception.class),并且查询图书库存时使用 selectByIdForUpdate,让数据库对这条图书记录加行锁。这样两个读者同时借同一本书,后一个会等前一个事务结束,然后发现库存不足,不会出现超借。如果不想用悲观锁,也可以换成 MyBatis-Plus 乐观锁 @Version,但多表事务场景下,我更推荐锁行,代码路径更直观,也不容易引入重试逻辑。
2.3 预约入馆与限流功能表设计
预约入馆是疫情场景下最有技术含量的模块。我用了两张表:visit_slot 时段表和 visit_record 预约记录表。visit_slot 字段包括 id、slot_date、start_time、end_time、max_quota、booked_count、status。visit_record 字段包括 id、reader_id、slot_id、visit_date、status、temperature、health_status、create_time。
预约限流的核心是防超卖。最稳妥的方式不是“先查名额再更新”,而是用一条原子 SQL 直接扣减:
UPDATE visit_slot SET booked_count = booked_count + 1 WHERE id = #{slotId} AND slot_date = #{date} AND status = 1 AND booked_count < max_quota;执行这条 SQL 后,如果受影响行数为 1,说明预约成功;如果为 0,说明名额已满。这个方案不需要引入 Redis,也不用先 select 再 update,天然避免并发超卖。更新成功后,再往 visit_record 插入一条预约记录。注意 update 和 insert 要放在同一个事务里,保证数据一致。
visit_record 里的 health_status 我存的是管理员在读者入馆时登记的“正常/异常”标记,体温 temperature 单独一个字段。这些信息属于用户的隐私数据,接口返回时要脱敏,列表页也不要一次性把所有字段都暴露出来。预约记录的分页查询是底线,排序按创建时间倒序,管理员能看到谁预约了哪个时段、是否到馆、体温是否正常。
2.4 MyBatis-Plus 的自动填充、逻辑删除和分页配置
这套项目里,MyBatis-Plus 的配置细节直接决定了开发效率。逻辑删除我使用全局配置,在 application.yml 里设置 logic-delete-field: deleted,并在实体类的 deleted 字段上加 @TableLogic。这样框架生成 SQL 时会自动追加 deleted = 0 条件,不需要在每个查询接口里手动写。
时间字段统一用 create_time 和 update_time,通过自定义 MetaObjectHandler 实现自动填充。insert 时自动填 createTime,update 时自动填 updateTime。不要在每个 Service 里手动 setCreateTime,很容易漏一个字段,而且不统一。分页插件记得在配置类里显式创建 MybatisPlusInterceptor 并添加 PaginationInnerInterceptor,否则 selectPage 虽能返回数据,但 total 总数始终是 0。很多人第一次用 MyBatis-Plus 都会在这里踩坑。
如果预约模块不用原子 SQL,而是走乐观锁,可以在 visit_slot 表加 version 字段,更新时 set version = version + 1 where version = 旧版本号,失败后重试。我实测下来,对于日预约量几千笔的小系统,原子 SQL 的方案代码更少、逻辑更容易讲清楚,所以最终选型没有用乐观锁。
3. 前后端核心实现与避坑记录
3.1 后端代码分层与统一响应
后端项目结构按 controller、service、mapper、entity、common 分包。common 里放统一返回结果 Result、全局异常处理器、常量类。接口统一返回 Result 格式,字段是 code、message、data。这个设计看起来很基础,但尽早统一能省掉大量联调时间,不然每个接口返回结构都不一样,前端 Axios 封装就没法做。
全局异常处理器也是必备组件。方法参数校验异常、业务异常、数据库异常应该分别捕获,返回稳定的提示文案。比如借书时库存不足,我在 Service 里抛 BusinessException,异常处理器把它转成 code=400、message=“图书库存不足”,前端直接弹出提示。千万不能让堆栈信息直接暴露给页面,既不安全也不友好。
权限控制我用了 JWT 加拦截器,没有上整套 Spring Security。原因很简单,系统角色就两种,拦截器里校验 token 和角色完全够用。登录接口、图书检索接口放入白名单,其余接口通过自定义注解判断角色,没有权限就返回 403。开发时先在前端隐藏菜单还不够,接口层必须做校验,否则只要懂一点接口知识就能绕过前端直接调数据。
借书的核心逻辑可以这样写:
@Transactional(rollbackFor = Exception.class) public void borrowBook(Long readerId, Long bookId) { Reader reader = readerMapper.selectById(readerId); if (reader == null || reader.getStatus() != 1) { throw new BusinessException("读者不存在或已停用"); } Book book = bookMapper.selectByIdForUpdate(bookId); if (book == null || book.getAvailableStock() < 1) { throw new BusinessException("图书库存不足"); } long count = borrowRecordMapper.countBorrowing(readerId); if (count >= reader.getMaxBorrowCount()) { throw new BusinessException("已达到最大借书数量"); } BorrowRecord record = new BorrowRecord(); record.setReaderId(readerId); record.setBookId(bookId); record.setBorrowTime(LocalDateTime.now()); record.setDueTime(LocalDateTime.now().plusDays(30)); record.setStatus(0); borrowRecordMapper.insert(record); book.setAvailableStock(book.getAvailableStock() - 1); bookMapper.updateById(book); }selectByIdForUpdate 是我们在 BookMapper 里自定义的方法,对应 SQL 是 SELECT * FROM book WHERE id = #{id} AND deleted = 0 FOR UPDATE。加上事务之后,它能保证同一时刻只有一个线程在操作这本图书的库存,这是图书借还不超卖的关键。
3.2 Vue3 前端工程搭建与请求封装
前端用的技术栈是 Vite + Vue3 + Pinia + Element Plus。Vite 的 dev server 启动非常快,组件热更新基本秒级反馈。Pinia 相比 Vuex 少了很多模板代码,在管理后台里存用户信息、token 非常方便。项目里我用 Pinia 维护了一个 user store,保存登录用户的 id、用户名、角色和 token。
Axios 请求层单独封装成一个 request.js,拦截器里做了三件事:
import axios from 'axios' import { useUserStore } from '@/stores/user' import router from '@/router' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } if (res.code === 401) { ElMessage.error('登录状态已失效') userStore.clear() router.push('/login') } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )路由权限在 router.beforeEach 里做。路由 meta 里标记 requiredRole,前端进入页面之前判断当前用户角色是否匹配。拿不到角色或者角色不匹配就重定向到登录页或 403 页。这套方案虽然简单,但能挡住“读者直接输入管理员页面地址”这种基础问题。
3.3 前后端联调、跨域和并发限流实测
开发环境跨域不要靠后端 @CrossOrigin 硬解,更不要在前端写死后端完整地址。我用 Vite 的 proxy 把 /api 开头的请求代理到 http://localhost:8080,开发时改起来方便,生产环境再交给 Nginx 反向代理。
联调时最容易出问题的其实是日期格式。后端如果不统一序列化规则,前端拿到的时间字符串可能带 T,也可能直接是时间戳。我的方案是在全局配置 Jackson 的日期格式为 yyyy-MM-dd HH:mm:ss,所有 LocalDateTime 字段都按这个格式输出,前端组件直接显示,省去二次格式化。
限流接口的并发测试,我写过一个简单的并发脚本,模拟 50 个读者同时抢同一个时段。因为预约名额扣减用的是原子 SQL,最终 booked_count 始终不会超过 max_quota。这里有个容易被忽略的细节,原子 SQL 所在的事务一定要短小精悍,不要在里面做远程调用、发短信这些耗时操作,否则数据库连接很容易被占满,系统整体变慢。
3.4 预约入馆完整流程的现场还原
预约入馆的完整链路是这样的:读者登录后,进入预约页面,选择日期和时段。前端通过接口把该时段剩余名额显示出来,但真正抢名额的时候不能信任前端显示的数字,只能作为参考。读者点击提交预约,后端执行原子 SQL 尝试扣减名额。如果成功,插入预约记录,返回预约成功;如果失败,返回“该时段名额已满”。随后读者在入馆时,工作人员搜索预约记录,核对身份并登记体温和健康状态,点击确认入馆,预约记录状态变为“已入馆”。
前端我会在页面顶部加上数据刷新按钮和倒计时提示,避免读者长时间停留在一个过期名额页面上。预约成功后生成一个预约编号,查询时只需要输入编号或手机号后四位,就能快速定位到预约记录。
4. 文档、启动与部署全流程
4.1 项目文档到底应该包含什么
标题里写了含文档,这里多说几句。一套合格的项目文档不需要花哨,但至少要有四件套:需求说明、数据库设计说明、接口文档、部署文档。
需求说明要写清楚角色划分和功能列表,最好配上简单用例。数据库设计说明以表为单位,写清楚字段含义、类型、约束、默认值和表关系。接口文档只要把路径、请求方法、请求参数、响应示例写清楚,能导入到 Apifox 或 Postman 里调试更好。部署文档要写清楚从安装环境到启动成功的每一步,包括 MySQL 建库、配置文件修改、Maven 构建、npm install 这些动作。
如果是课程设计或答辩用,我还会额外加一份演示脚本,把核心功能的操作路径和预期结果列出来。比如“图书库存不足时借书,页面提示什么”“预约满员时提交,返回什么文案”“停用读者登录,是否被拦截”。演示脚本能帮主讲人减少临场发挥的不确定性,项目深度也能体现得更充分。
4.2 本地从零启动的完整操作步骤
先按版本要求装好 JDK8 或 11、Maven 3.6+、MySQL8.0、Node.js 16 或 18 LTS。创建数据库时选 utf8mb4 字符集,然后执行项目里的 init.sql,导入表结构。导入成功后,登录数据库检查一下核心表数量,别直接启动后端才发现表缺失。
第二步修改后端配置文件 application.yml。重点改三项:数据库连接地址、用户名、密码。连接地址参考下面这段:
spring: datasource: url: jdbc:mysql://localhost:3306/library_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver第三步启动后端。在项目根目录执行 mvn spring-boot:run,或者用 IDEA 直接运行启动类。后端起来之后先看控制台有没有报错,再用登录接口测试能否正常返回 token。如果能请求通,说明数据库连接和框架配置都没问题。
第四步启动前端。进入 frontend 目录执行 npm install,国内网络慢可以配置镜像源,然后 npm run dev。启动后浏览器访问 Vite 默认端口 5173,用预置账号登录。把前后端同时跑起来之后,完整走一遍“预约入馆—借书—还书—生成借阅记录”的链路,确保核心流程没有断点。
我踩过的一个低级坑是项目路径里包含中文,导致某些依赖编译失败,后来把整个项目放到纯英文目录下才恢复正常。如果你遇到诡异的编译错误,先检查路径。
4.3 MySQL8.0 连接配置与常见报错
MySQL8.0 连接上最常见的问题有三个。第一个是 Public Key Retrieval is not allowed,原因是 8.0 默认使用 caching_sha2_password 认证,客户端首次连接需要拿公钥,解决方案就是在 JDBC URL 里加 allowPublicKeyRetrieval=true。第二个是时区报错 The server time zone value ...,加 serverTimezone=Asia/Shanghai 就能解决。第三个是驱动类名,8.x 要用 com.mysql.cj.jdbc.Driver,不是旧的 com.mysql.jdbc.Driver。SpringBoot2 默认数据源是 HikariCP,连接池参数在配置文件里调整 maximum-pool-size 和 minimum-idle,开发环境不用动,生产环境再按访问量调。
4.4 常见问题排查速查表
我把实际运行中容易遇到的问题整理成一张表,方便对照排查。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 后端启动报找不到主类 | JDK 版本不对 | 切换 JDK8/11,并检查 Maven 编译器版本 |
| SQL 语法错误 | 表名或字段名用了关键字 | 重命名字段,或者用反引号包裹 |
| 分页查询 total 始终为 0 | 没配置分页插件 | 添加 MybatisPlusInterceptor + PaginationInnerInterceptor |
| 前端请求 404 或跨域 | 代理没配对 | 检查 Vite proxy 和后端 context-path |
| 日期相差 8 小时 | 时区配置缺失 | 在 JDBC URL 加 serverTimezone=Asia/Shanghai |
| 逻辑删除数据插入失败 | 唯一索引与逻辑删除冲突 | 将唯一索引改成 (业务字段, deleted) 组合索引 |
| 图书库存变成负数 | 借还缺少行锁或乐观锁 | 在事务中使用 select ... for update |
| npm install 报错 | Node 版本过高 | 使用 Node 16 或 18 LTS |
| 接口返回密码字段 | 直接返回了实体 | 实体转 VO,过滤敏感字段 |
5. 实操心得与扩展方向
5.1 我踩过的几个坑
这套系统整体不算难,但有几个细节很容易让人栽跟头。第一个是逻辑删除和唯一索引的冲突,前面已经提到了,这里再强调一次,凡是逻辑删除的表,设计唯一索引时一定要把逻辑删除字段加进去。第二个是事务内做耗时操作,我一开始把借书短信通知写在事务里,结果短信接口超时,整笔借阅回滚,读者明明借书成功了却收到失败提示,后来把这类操作挪到事务外,或者丢进异步任务。第三个是接口返回数据不干净,最初图方便直接把数据库实体返回给前端,导致密码字段出现在响应里,后来统一改成 VO,把所有敏感字段都过滤掉,这个教训非常深刻。
还有一个小技巧,接口层的权限校验一定要做,哪怕前端已经隐藏了菜单。我在联调时故意用接口工具直接调用管理员接口,发现只靠前端路由权限拦截根本防不住,后来在拦截器里补齐了角色校验才堵上。做管理系统,接口安全远比页面美观重要。
5.2 这套系统还能怎么扩展
如果想继续演进,我建议优先做三个扩展。第一个是预约消息提醒,预约成功后通过邮件或短信通知读者,减少预约后不到馆的情况。第二个是报表可视化,图书馆管理者很关心每天入馆人数、借还书峰值、热门图书排行,可以把数据库里的数据用定时任务聚合到统计表,再用图表页面展示。第三个是扫码借还书,在 book 表增加二维码或条形码字段,移动端扫一扫完成借阅,这在真实图书馆场景里非常实用。
也可以把疫情下的预约模式进一步抽象成通用的“空间预约服务”。会议室、教室、健身房都能复用同一套时段管理和名额扣减逻辑,只是资源表和业务字段不同。这样一来,这套系统的价值就不只限于图书馆,而会变成一个可复用的基础模块。我在实际复盘时觉得,当初把限流逻辑做成原子 SQL,最大的收益就是这套逻辑可以原封不动搬到其他预约场景里,以后再做类似功能,直接复制核心思路就够了。