首页 / 资讯中心 / 文章详情

从借书还书看Java图书管理系统的状态与事务设计

从借书还书看Java图书管理系统的状态与事务设计 ★ FEATURED ARTICLE
简介一份基于 Java 的图书管理系统项目资料适合 Java 初学者、学生以及需要课程设计的开发者学习参考。项目涵盖图书信息管理、用户管理、借阅归还、多条件查询和统计分析等核心功能模块并涉及 Swing/JFX 界面、集合框架、MVC 设计模式及数据库持久化等关键知识点。压缩包7z 格式共 38 个文件其中包含 14 个 Java 源文件、16 个已编译 class 文件以及项目配置、文本数据说明和界面截图等辅助资料包体仅 47KB结构紧凑便于快速导入 IDE 查看。已有 1525 人学习下载可作为 Java 编程实践的入门素材。通过阅读和运行源码学习者能直观理解面向对象设计、业务逻辑分层和数据库交互的完整流程无论是课堂作业、课程设计还是自学练手都能从中获得可运行的参考实现并在此基础上扩展新功能。1. 图书管理系统难的不是图书而是借还“Java写的图书管理系统”听起来是课设清单里的常客甚至有点老掉牙但你真上手做一遍就会发现图书和读者的增删改查一天就能写完借书还书那几步状态流转能让人连着调三天。系统的复杂度不在“图书”而在“借出去”和“还回来”这两个动作上书要改状态、借阅记录要落库、逾期要算罚金任何一步中断数据就花了。这篇文章就围绕这套系统把需求拆解、建表、连库、借还逻辑和几个高频坑讲透适合正在做课设、或想拿经典项目练手的人照着复现并继续扩展。2. 动手前先定好架构与状态选型这事没有标准答案2.1 核心需求拆解别一上来就建五张表很多第一次做这个项目的人打开编辑器第一件事就是把“图书表、读者表、分类表、出版社表、借阅表”全建出来最后分类表和出版社表成了摆设。需求分析得往回拉一拉谁是使用者典型是小图书馆的管理员或前台他们要录入图书、登记读者、办理借书、办理还书、查某本书在哪、查某个读者借了什么。这是最小闭环。图书分类、出版社统计都是加分项不是第一版的地基。我一般会把需求收敛成一个表格只留四条主线核心用例关键信息落地数据结构图书录入与维护书名、作者、ISBN、分类、状态book 表读者登记与维护姓名、学号、联系方式、最大借书量reader 表借书与还书哪本书、哪个读者、借出时间、应还时间、逾期罚金borrow_record 表 book.status查询统计某本书状态、读者在借列表、借阅排行borrow_record 联表查询查询统计这一条在课设答辩里经常被问到别删。查询不只是“列出所有图书”还包括“某人正在借哪些书”“某本书状态如何”“最近借阅记录”。这些看起来简单实际都会指向借阅记录表所以要给这张表留好字段空间。另一个容易被忽略的是“状态”这个词。别把状态想成一个页面上的下拉框把它想成一条业务规则书在馆才能被借书借出后还书时才能恢复在馆同一本书同一时间只能有一个借出状态。这条规则不是写死在程序注释里的而是落进 SQL 的 where 条件的。做系统前把这条规则写出来建表的时候就不会只想着“存什么”而是会想“什么时候允许改”。权限要不要做成两个角色常见做法是普通读者登录后只能查书、看自己的借阅记录管理员才能办借还。如果只做单管理员桌面端可以先不做登录数据权限问题用“管理员就是操作人”来解释。如果要做登录建议放到第二版再补第一版先把借还流程的数据一致性保住。2.2 技术选型为什么最常见的组合是 Spring Boot MyBatis MySQL标题只写了“Java写的”没限定框架所以选型上有两条路Spring Boot Spring MVC MyBatis MySQL以及课程里常用的原生 Servlet JSP JDBC。前者是实际项目中的主流后者是教学场景里的“硬核路线”。我的建议是如果不是学校明确要求“必须手写 Servlet 过滤器、手写 JDBC 工具类”直接走 Spring Boot。理由不是跟风而是这个系统真正麻烦的逻辑在事务和状态更新上Spring Boot 的自动配置和声明式事务能让你把精力集中在业务层而不是花在配置连接、处理异常、手动 commit 上。下面用表格对比两条路线对比项Spring Boot 组合原生 Servlet JSP 组合开发效率高依赖管理、内嵌容器、自动配置低web.xml、连接池、编码都要手配事务控制Transactional 声明式边界清晰Connection 手动 commit/rollback漏一处就脏数据页面渲染Thymeleaf 模板语法贴近 HTMLJSP 语法改动需重启容器启动调试一键启动内置 Tomcat外置容器部署调试链路长适用场景练手、课设、真实项目学校硬性要求、想理解底层这条路线的环境选择常见做法是 JDK 8 或 11 配 Spring Boot 2.x或者 JDK 17 配 Spring Boot 3.x二者选一组即可不要追求最新。数据库选 MySQL 8不是为了新特性而是它的驱动和时区参数更成熟坑少。页面渲染用 Thymeleaf它是服务端模板能直接在 HTML 里写 th:each 循环出列表也能用表达式显示错误信息对这个体量的系统足够。如果后面想前后端分离再把 Controller 改成返回 JSON 也不费事。为什么不用 Spring Data JPAJPA 自动建表、自动映射确实方便但图书管理里有组合查询、分页、条件更新这些 SQL 用注解和方法名不太好表达调优时还得翻出底层 SQL 看。MyBatis 把 SQL 写在 XML 里想加条件加索引都很直接排查问题少一层黑盒。为什么不用 Spring Cloud、Redis、消息队列一套单机管理系统用不上引进来只会让答辩变成“为什么要用”的连环追问。技术选型到这个程度刚刚好足够轻足够能讲清楚。3. 从建表到跑通查询Java读写图书数据的骨架搭建3.1 三张表把业务稳住图书、读者、借阅记录需求收敛到最后表不需要多三张核心表就够图书表负责书的属性读者表负责人的属性和借阅限制借阅记录表负责每一次借还行为。真正让系统稳定的是借阅记录表的设计因为它要同时回答“这本书借给谁了”和“这个人借了什么书”两个问题所以必须把 book_id 和 reader_id 都放进去。直接看建表语句这段 SQL 在 MySQL 8 里可以直接执行CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL COMMENT 书名, author VARCHAR(100) DEFAULT NULL, isbn VARCHAR(32) DEFAULT NULL, category VARCHAR(50) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-在馆 1-已借出, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除0-正常 1-已删除, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_title (title), KEY idx_isbn (isbn) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图书表; CREATE TABLE reader ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, student_no VARCHAR(20) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, max_borrow INT NOT NULL DEFAULT 5 COMMENT 最大在借数量, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 0-停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT读者表; CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, reader_id BIGINT NOT NULL, borrow_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, due_time DATETIME NOT NULL COMMENT 应还时间, return_time DATETIME DEFAULT NULL COMMENT 实际归还时间, fine DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 逾期罚金, KEY idx_book_id (book_id), KEY idx_reader_id (reader_id), CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book (id), CONSTRAINT fk_borrow_reader FOREIGN KEY (reader_id) REFERENCES reader (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT借阅记录表;几个关键设计说清楚。book 表里单独放 status而不是每次去 borrow_record 里现算是因为首页表格和搜索页需要高频展示“可借/已借出”冗余一个状态字段让查询简单状态一致性交给后面的业务事务保证。due_time 单独存 DATETIME不存“借期多少天”这样到期提醒和逾期罚金都以这个字段计算不会因为系统日期变化出现口径不一致。fine 直接放借阅记录表里是最简单的罚金方案适合课设和轻量管理如果还要做缴费流水再拆一张罚金表不迟。字符集统一用 utf8mb4别用 utf8否则生僻字和表情符号入库会变问号。索引方面图书表的 title 和 isbn 建了普通索引借阅记录表的 book_id 和 reader_id 建了索引因为查“某本书的借阅历史”和“某读者的在借列表”都会走这两个外键列。这里故意没有给 borrow_record 加“是否已归还”的唯一索引因为 MySQL 对多个 NULL 值不限制唯一性靠索引拦不住重复借阅这个约束只能在业务层用条件更新实现第 4 章会讲。建好表之后别急着写实体类。先配数据源把一条查询跑通再补 Java 代码就顺了。3.2 数据源配置与第一个查询从 yml 到 Mapper XML项目生成后我习惯把 application.properties 改成 application.yml层次清楚。下面是一份最小可运行的数据源配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你的密码 thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/**/*.xml type-aliases-package: com.example.library.entity这段配置里有三个参数是血泪经验。characterEncodingutf8 负责让中文在 JDBC 连接层不乱码serverTimezoneAsia/Shanghai 解决 MySQL 8 与本地时区不一致导致的报错allowPublicKeyRetrievaltrue 是为了避免 MySQL 8 在首次连接时因公钥获取方式连接失败。开发阶段把 thymeleaf.cache 关掉改完页面刷新就生效不用重启。然后按 controller / service / mapper / entity 四层建包。实体类字段和表字段一一对应时间类型统一用 LocalDateTime不要混搭 java.util.Date否则序列化和比较都会别扭。BookMapper 接口定义一个带关键词的分页查询Mapper public interface BookMapper { ListBook page(Param(keyword) String keyword, Param(offset) int offset, Param(limit) int limit); }对应的 XML 写在 resources/mapper/BookMapper.xml 里?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.library.mapper.BookMapper select idpage resultTypeBook SELECT id, title, author, isbn, category, status, create_time FROM book where if testkeyword ! null and keyword ! AND title LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY id DESC LIMIT #{offset}, #{limit} /select /mapper为什么这里用where标签而不是写WHERE 11where会自动去掉第一个多余的 ANDkeyword 为空时它整个不输出 WHERE查询变成全量分页。LIKE 拼接用CONCAT(%, #{keyword}, %)不要用%${keyword}%后者是字符串直接拼进 SQL关键字里带单引号会直接报错甚至构成注入#{keyword}是预编译参数安全。这个坑在第五章还会展开。Controller 写一个最简单的 JSON 接口来验证整条链路RestController RequestMapping(/api/books) public class BookController { private final BookMapper bookMapper; public BookController(BookMapper bookMapper) { this.bookMapper bookMapper; } GetMapping public ListBook page(RequestParam(required false) String keyword, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int limit) { return bookMapper.page(keyword, (page - 1) * limit, limit); } }跑起来后访问http://localhost:8080/api/books?keywordJavapage1limit10能按书名分页查出数据就说明数据源、MyBatis、实体类三层全通了。注意页码转 offset 的计算放在 Controller 是为了示例简单真实项目里一般抽一个分页参数对象。构造器注入是 Spring 推荐的方式也方便单测。这一步跑通后图书和读者两个模块的列表、新增、编辑就是体力活真正要动脑的是借阅。4. 借书还书的正确姿势状态流转与事务边界4.1 借书的下限实现一条条件更新挡住并发借书业务看起来只需要三步把书状态改成已借出、给读者在借数量加一、插入一条借阅记录。但实际写的时候初学者最容易犯的错是“先查出书然后判断状态再更新”这在单用户测试时没问题一旦两个管理员同时对同一本书点击“借出”两个请求都读到“在馆”然后都执行更新最后这本书被借给两个人系统直接翻车。所以我在借书逻辑里定的底线是用一次带条件的 UPDATE 完成状态竞争靠“受影响行数”判断谁成功。下面是一个最小但完整的借书服务方法Service public class BorrowService { private final BookMapper bookMapper; private final ReaderMapper readerMapper; private final BorrowRecordMapper borrowRecordMapper; public BorrowService(BookMapper bookMapper, ReaderMapper readerMapper, BorrowRecordMapper borrowRecordMapper) { this.bookMapper bookMapper; this.readerMapper readerMapper; this.borrowRecordMapper borrowRecordMapper; } Transactional(rollbackFor Exception.class) public void borrow(Long bookId, Long readerId) { Book book bookMapper.selectById(bookId); if (book null || book.getStatus() ! 0) { throw new BusinessException(图书不存在或已被借出); } Reader reader readerMapper.selectById(readerId); if (reader null || reader.getStatus() ! 1) { throw new BusinessException(读者不存在或已停用); } int borrowing borrowRecordMapper.countBorrowing(readerId); if (borrowing reader.getMaxBorrow()) { throw new BusinessException(该读者已达到最大借阅数量); } int rows bookMapper.updateStatus(bookId, 0, 1); if (rows ! 1) { throw new BusinessException(手慢了这本书刚被借走请刷新后重试); } BorrowRecord record new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowTime(LocalDateTime.now()); record.setDueTime(LocalDateTime.now().plusDays(30)); borrowRecordMapper.insert(record); } }重点拆解三个点。Transactional(rollbackFor Exception.class) 把“改书状态 插借阅记录”包在同一个数据库事务里任何一步失败都整体回滚否则会出现“记录没插进去但书已经被借走”的脏数据。updateStatus 的 SQL 是核心必须带上旧状态条件UPDATE book SET status #{newStatus} WHERE id #{bookId} AND status #{oldStatus}这个 SQL 的 where 里 status 0 意味着同一本书同一时刻最多只有一个请求能更新成功第二个请求的受影响行数为 0代码里就会抛异常提示“刚被借走”。这比先查再改可靠得多。countBorrowing 统计的是该读者 return_time 为 NULL 的记录数也就是“在借中”的数量它和 max_borrow 的比较是防止一个读者把全馆的书都借走。注意updateStatus 这条 SQL 必须在事务方法里执行。如果单独抽成一个不带事务的方法会出现“书改了、记录没插上”的中间态下次还书时找不到对应记录。细心的读者会发现这里没有判断“同一本书是否已经被同一个读者借过”因为一本书只要状态已经是 1其他读者包括同一个读者都借不了。这个方案对“一本书同时只能被一人借”的模型是成立的。如果以后要支持“一本书多个副本”就得把 book 表拆成“书目 副本”或者加一个 copy_no 字段这是后话。4.2 还书与逾期罚金别把业务逻辑堆在控制器里还书是借书的镜像操作把书状态恢复为 0把借阅记录里的 return_time 写上去如果超过 due_time 还要计算罚金。很多代码会把这些逻辑直接写在 Controller 里一个方法写几十行 if最后整个类又长又难测。我在这个系统里的约定是Controller 只接收参数和返回结果所有分支都放进 Service。还书方法如下Transactional(rollbackFor Exception.class) public void returnBook(Long recordId) { BorrowRecord record borrowRecordMapper.selectById(recordId); if (record null || record.getReturnTime() ! null) { throw new BusinessException(借阅记录不存在或已归还); } LocalDateTime now LocalDateTime.now(); int rows bookMapper.updateStatus(record.getBookId(), 1, 0); if (rows ! 1) { throw new BusinessException(图书状态异常请核对数据库); } record.setReturnTime(now); if (now.isAfter(record.getDueTime())) { long days Duration.between(record.getDueTime(), now).toDays(); if (days 0) { days 1; } record.setFine(new BigDecimal(days).multiply(new BigDecimal(0.5))); } borrowRecordMapper.updateReturnInfo(record); }这里的逾期计算按天取整不足一天按一天算每天罚金 0.5 元这只是参考口径实际金额按图书馆规则来。updateStatus(bookId, 1, 0) 和借书用的是同一个方法只是新旧状态参数反过来保证了还书时只有“已借出”的书才能被还。一个容易被忽略的细节是还书必须通过 recordId 找到那条记录而不是只传 bookId。因为同一本书历史上有过多次借出只传 bookId 时无法确定还的是哪一次。如果前端只传了 bookId后端要查“该书当前未还的那条记录”再更新否则就会把历史记录覆盖掉。这也是建表时强调 return_time 默认空、非空表示已归还的原因它让“当前在借中的记录”这个查询变得极简单。到这里核心业务闭环了。但项目能不能稳定跑下去还要看几个容易踩的坑我把实际项目里总会遇到的五个问题放在下一章。5. 避坑实录从乱码到并发失序的五个现场5.1 中文乱码与日期解析最先遇到的两个数据进出坑先看乱码。现象页面上输入中文书名保存后数据库里变成“???”或者页面上正常显示导出的接口返回却是中文乱码。原因字符集问题大多不是某一个环节出错而是三层各错一处JDBC 连接串没加 characterEncodingutf8、MySQL 表建库时默认成了 latin1、前端页面没声明 UTF-8 编码。只改一处往往无效。解决连接串统一加 characterEncodingutf8建表指定 CHARSETutf8mb4前端页面设置 UTF-8 编码三者对齐后中文问题基本绝迹。排查时可以先用数据库客户端直接插入一条中文看库里能不能正常存能存说明表没问题再往连接串方向查。字符集属于那种看起来像玄学、其实就是链路一致性的问题把三层定成一个标准以后不会再犯。再看日期解析。现象前端传“2025-06-01”后端 LocalDate 参数直接报类型转换错误接口变 400。原因Spring MVC 默认对 LocalDate 的格式化规则是 ISO 标准格式但对带横线的日期字符串需要显式指定。这是 LocalDate 和 java.util.Date 一个很大的差异Date 经常用全局配置解决LocalDate 往往要加注解。解决在 Controller 入参上写 DateTimeFormat(pattern yyyy-MM-dd) LocalDate borrowDate如果是从表单提交实体类字段上也要加同一个注解。我倾向于在实体类字段上统一加因为 Controller 和 Service 都会用到这个字段。如果前端传的是时间戳数字就用 JSON 序列化里的 JsonFormat 处理和 DateTimeFormat 是两个机制不要搞混。5.2 SQL 层两个坑动态条件拼接与并发更新动态拼接的坑。现象图书搜索框输入“%”或英文单引号时查询直接报 SQL 语法错误某些关键字能查出不该看到的数据。原因这是把关键字直接用 ${keyword} 拼进了 SQL。MyBatis 里 ${} 是字符串替换它会原样拼进 SQL 语句% 和单引号破坏了语法同时这也是 SQL 注入的高发写法因为输入内容被当成了 SQL 结构而不是参数值。解决动态拼接统一用where标签加#{param}预编译参数LIKE 用 CONCAT(%, #{keyword}, %)让特殊字符只作为参数值参与比较。如果需要动态排序字段比如前端传 sort 参数决定 ORDER BY 哪一列不要直接把 sort 拼进 SQL先做一个白名单映射前端传“title”就映射成“title”传“createTime”就映射为“create_time”其他值一律走默认排序。并发更新的坑。现象两个管理员同时借同一本书日志里两个请求都查到了“状态正常”最后借阅记录表插入了两条记录书状态却是已借出。原因这是典型的“先查后改”竞态。两个事务都先 SELECT 到 status0都过了业务判断然后都执行 UPDATE最后一个事务把状态改成 1但两个事务都已经插了借阅记录。SELECT 不加锁时这条链路不是原子操作。解决用第 4 章的条件更新方案UPDATE book SET status 1 WHERE id ? AND status 0 后判断受影响行数而不是先查状态再更新。同时记住事务的几个失效来源排查时按这三条走同类里 this 调用本类方法导致代理失效、异常被 try-catch 吞掉、方法不是 public 或没有通过 Spring 管理。出现事务不生效百分之八九十是这三种情况之一。5.3 物理删除图书后历史记录从哪找回现象系统跑了一周后想删掉一条录入错误的图书点击删除却报外键约束错误后来改成先删借阅记录再删图书结果书删了但某位读者“历史借阅”页面里原来的记录对不上书名页面出现空值。原因borrow_record 表通过外键引用了 book 表直接删 book 会被约束拦下强行连坐删除借阅记录又会破坏历史数据因为读者查看“以前借过什么”依赖的正是这条记录。这是物理删除路线在关联数据场景下的必然结局。解决图书和读者都改成逻辑删除用建表时预留的 deleted 字段。删除操作变成 UPDATE book SET deleted 1 WHERE id ?所有查询条件里过滤 deleted 0。借阅记录表里的 book_id 仍然指向存在的记录历史数据完整外键约束也不会拦。这个方案的一个附带好处是管理员误删图书后还能恢复相当于给系统加了一层后悔药。代价是每个查询都要记得带上 deleted 0这个可以在 MyBatis 的通用查询 SQL 里统一加上靠规范约束。这五个坑都是在项目接近完成时才会暴露的前期功能少不容易触发越到后面越要警惕。6. 能跑以后先把组合检索和借阅排行升级一下借还功能闭环之后这个系统的价值还差最后一步让管理员能从数据里看出问题。与其把时间花在美化页面上我会先把两个查询升级上去一个是多条件组合检索一个是借阅排行。组合检索是基于 book 表加一个“分类 状态 书名关键词”的联合查询响应前端侧边栏的筛选条件。Mapper 里加一个方法select idsearch resultTypeBook SELECT * FROM book where if testtitle ! null and title ! AND title LIKE CONCAT(%, #{title}, %) /if if testcategory ! null and category ! AND category #{category} /if if teststatus ! null AND status #{status} /if AND deleted 0 /where ORDER BY create_time DESC /select注意 AND deleted 0 放在where内部这样即便前三个条件都为空where 标签也能正确生成只含该条件的查询。status 用if teststatus ! null而没有判断! 因为它是数字类型空字符串判断反而会有类型转换隐患。借阅排行放在管理员首页一条 SQL 直接统计最近 30 天被借最多的书SELECT b.title, COUNT(br.id) AS borrow_times FROM borrow_record br JOIN book b ON b.id br.book_id WHERE br.borrow_time DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY b.id, b.title ORDER BY borrow_times DESC LIMIT 10;这个查询放到 Mapper 里返回一个包含 title 和 borrowTimes 的视图对象即可。DATE_SUB 和 INTERVAL 是 MySQL 日期函数别在 Java 里先算好日期再拼字符串那样既绕又容易踩时区偏差。排行榜只统计已落库的 borrow_record所以不会因为图书逻辑删除而丢数据。我的习惯是每次接手这类管理系统先不急着加功能而是把“哪些表会同时被修改、哪些查询每天都会有人点”捋一遍。借阅记录和图书状态是一对联动关系适合放在一个事务排行榜和组合检索是高频率只读操作适合用索引和 SQL 优化。把这些边界想清楚再动手比多写两个页面更值钱。希望这个经典项目的拆解过程能帮到你也欢迎你按自己的业务量把罚金阶梯、副本管理、读者自助续借一个个加进来这套骨架是撑得住的。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站