我做Java开发这些年,看过不少项目源码,如果要挑一个最适合“面试练习、项目实战、源码阅读”三个方向一起练手的完整系统,无人图书借阅系统绝对排得上号。它没有Spring源码那么抽象难啃,但足够完整:借书、还书、预约、逾期、对账、防盗联动,从业务到技术全都有,而且把状态机、并发控制、事务边界、设备交互、定时任务这些后端核心问题串得很清楚。这篇博文就沿着这套系统的源码脉络,把从借书到还书、从终端联动到后台补偿的完整链路拆开讲一遍。
无人图书借阅系统解决的核心问题,是“没有管理员在场时,如何让借还书流程仍然可靠、可审计、可追踪”。对Java开发者来说,这套代码最大的价值在于:它不是一个只做增删改查的演示项目,而是一个有真实业务复杂度的系统。不管你是准备面试、想练手源码阅读,还是打算做图书管理类毕设,都能从里面找到可以直接拿来用的设计思路。我会按这样的逻辑展开:先看清系统需求和模块地图,再钻进修还书状态机,接着重点拆并发防重,然后看终端识别与防盗联动,再到定时任务和数据对账,最后聊聊本地运行这套源码一定会踩的坑。
1. 无人图书借阅系统到底在解决什么问题
1.1 从自助借还的现场场景倒推系统需求
很多人在读源码之前习惯直接打开项目结构,然后盯着代码发懵。我的建议是反过来,先想清楚这套系统在真实场景里要处理什么。你去一个无人值守的图书馆,看到的是自助借还机、门禁、RFID通道机这些硬件,但这些硬件后面所有判断动作,都是后端代码在支撑。
传统的图书借还需要管理员完成四件事:确认读者身份、检查图书可否外借、登记借阅记录、提醒或处理逾期。无人化改造之后,这些工作全部转移到了系统上。于是需求就变成了:
- 读者是谁?系统要支持身份证、扫码、人脸等自助认证方式。
- 这本书能不能借?不能只看书架位置,必须实时查询图书状态。
- 借了之后怎么防止不还?要和门禁、安全位联动,没走正规流程的书出门会报警。
- 逾期了怎么办?系统要自动计算费用、自动发提醒,逾期严重的还要冻结借阅资格。
- 系统出问题时怎么办?要靠每日对账、异常记录、人工复核工单来兜底。
把这些需求翻译成代码,就是读者管理、图书状态机、借阅流程、防盗联动、定时任务和数据对账这几大模块。这也是我建议你按这个顺序去读源码的原因:先有业务问题,再去找代码答案,效率比漫无目的地翻文件夹高很多。
1.2 四大核心实体与两条主流程
数据库表看着很多,但核心实体其实就四张:读者表、图书表、借阅记录表、预约表,其他表都是围绕它们做的扩展。
读者表记录身份信息和借阅资格,比如当前在借数量、最大借阅额度、状态是否冻结。图书表除了书目信息,还包含一个很关键的字段:status。这个状态字段是整个系统的业务枢纽。借阅记录表是一张借书单从借出到归还的完整生命周期。预约表解决“想借的书在别人手里”的问题,它的存在还会影响还书时的状态判断。
两条主流程分别是借书和还书。借书流程在无人场景下必须多一步设备联动:借阅成功后要通知终端解除图书安全位,否则读者出门时会被误报成“未借出”。还书流程则要多一步校验:归还的图书是否属于本馆,有没有对应的有效借阅记录,没有的话要走异常处理分支。这两条流程跑通了,整个系统的骨架就立起来了。
1.3 源码阅读的第一张地图:模块划分
读源码先看包结构,这是最直接的切入方式。这类系统通常采用 Spring Boot + Spring Data JPA/MyBatis + MySQL + Redis 的技术栈,包结构也基本固定。
entity:对应数据库表结构,承载业务数据。repository:数据访问层,负责SQL和ORM映射。service:业务逻辑层,借书、还书、续借、预约都在这里。controller:对外暴露HTTP接口,自助终端和管理后台都走这里。component:定时任务、消息推送、门禁联动等通用能力。device:封装RFID读写器、身份证读卡器、人脸识别设备等,对外提供统一接口。
拿到这张地图后,接下来要钻进最核心的业务逻辑,也就是图书状态机。因为借书、还书、预约、丢失、下架,本质上都是在改状态,状态设计得好不好,直接决定整个系统稳不稳。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 借书与还书的状态机设计:业务模型是这套源码的骨架
2.1 图书状态不该用if-else硬编码:六种状态的迁移规则
很多初学者在写图书管理小项目时,会给图书加一个 status 字段,用 0 表示在馆、1 表示借出,然后在每个方法里写 if-else 判断。这种写法在无人系统里撑不住,因为状态远不止两个。
这套源码里的图书状态有六种:AVAILABLE(可借)、BORROWED(已借出)、RESERVED(被预约)、LOST(丢失)、DAMAGED(损坏)、OFF_SHELF(下架)。状态之间不能随意跳转。举例来说,一本被预约的书在读者归还后应该直接进入 RESERVED,而不是先回到 AVAILABLE 再被预约;一本丢失的书不能被直接借出;一本下架的书只能走修补或淘汰流程。
这就是状态机思维。Java 代码里最直观的做法是用枚举定义状态,再用一张迁移表统一管理合法性校验:
java复制public enum BookStatus {
AVAILABLE, BORROWED, RESERVED, LOST, DAMAGED, OFF_SHELF
}
java复制public class BookStateMachine {
private static final Map<BookStatus, Set<BookStatus>> ALLOWED_TRANSITIONS = new EnumMap<>(BookStatus.class);
static {
ALLOWED_TRANSITIONS.put(BookStatus.AVAILABLE, Set.of(BookStatus.BORROWED, BookStatus.RESERVED, BookStatus.OFF_SHELF, BookStatus.DAMAGED));
ALLOWED_TRANSITIONS.put(BookStatus.BORROWED, Set.of(BookStatus.AVAILABLE, BookStatus.RESERVED, BookStatus.LOST, BookStatus.DAMAGED));
ALLOWED_TRANSITIONS.put(BookStatus.RESERVED, Set.of(BookStatus.BORROWED, BookStatus.AVAILABLE));
ALLOWED_TRANSITIONS.put(BookStatus.LOST, Set.of(BookStatus.OFF_SHELF));
ALLOWED_TRANSITIONS.put(BookStatus.DAMAGED, Set.of(BookStatus.OFF_SHELF, BookStatus.AVAILABLE));
ALLOWED_TRANSITIONS.put(BookStatus.OFF_SHELF, Set.of());
}
public static boolean canTransition(BookStatus from, BookStatus to) {
return ALLOWED_TRANSITIONS.getOrDefault(from, Set.of()).contains(to);
}
}
这样做的价值在于,所有合法性判断集中在一处,业务代码里不会再到处散落状态判断。以后如果要扩展新状态,比如“消毒中”,只需要改枚举和迁移表,业务层几乎不用动。
2.2 借书流程的代码实现与事务边界
状态定义好了,接下来看借书。这段代码值得反复读,因为它把一个看似简单的操作拆成了几个容易忽略的关键点。
java复制@Transactional
public BorrowResult borrowBook(ReaderDTO reader, String bookId) {
// 1. 锁定图书行,避免并发修改
Book book = bookRepository.findByBookIdForUpdate(bookId);
if (book == null) {
return BorrowResult.fail("图书不存在");
}
// 2. 校验状态是否允许借出
if (!BookStateMachine.canTransition(book.getStatus(), BookStatus.BORROWED)) {
return BorrowResult.fail("当前图书状态不可借: " + book.getStatus());
}
// 3. 如果图书处于RESERVED状态,只有预约者可以借
if (book.getStatus() == BookStatus.RESERVED
&& !book.getReservedReaderId().equals(reader.getReaderId())) {
return BorrowResult.fail("该书已被其他读者预约");
}
// 4. 校验读者资格:黑名单、在借数量上限、欠费
if (reader.getBorrowCount() >= reader.getMaxBorrowLimit()) {
return BorrowResult.fail("已达最大借阅数量");
}
if (ReaderStatus.FROZEN.equals(reader.getStatus())) {
return BorrowResult.fail("读者状态异常,请联系管理员");
}
// 5. 创建借阅记录并更新图书状态
BorrowRecord record = BorrowRecord.create(reader.getReaderId(), bookId);
borrowRecordRepository.save(record);
book.setStatus(BookStatus.BORROWED);
book.setBorrowedReaderId(reader.getReaderId());
book.setBorrowedTime(LocalDateTime.now());
book.setDueTime(LocalDateTime.now().plusDays(30));
bookRepository.save(book);
// 6. 通知终端解除安全位
deviceService.deactivateSecurityTag(book.getRfidCode());
return BorrowResult.success(record);
}
这里最值得琢磨的是 @Transactional 的事务边界。数据库操作和 deviceService.deactivateSecurityTag 在同一个事务里,如果解除安全位失败,整个事务回滚,借阅记录不会落库。但从业务角度讲,硬件操作不能回滚,如果设备调用失败,事务回滚后读者看到的是“借书失败”,可图书的安全位已经被解除了,反而会造成“显示未借出但能带出馆”的情况。
更稳妥的做法我会在后面第4章展开,这里先记住一个结论:本地事务与远程硬件操作的边界问题,是这类无人系统的核心设计难点。源码里如果看到“硬件联动失败不直接回滚,而是把记录先落库,再把设备状态标记为待同步,由后台补偿任务重试”这种设计,说明作者是真的考虑过生产环境的问题。
2.3 还书流程与预约触发的隐藏细节
还书流程比借书多一个非常容易忽略的细节:归还后图书状态怎么定。很多不够成熟的实现会写 BORROWED -> AVAILABLE,但这在无人系统里是不对的。正确的逻辑是,先查这本书有没有预约记录,有预约则直接进入 RESERVED 并触发预约通知,没有预约才回到 AVAILABLE。
java复制@Transactional
public ReturnResult returnBook(String bookId) {
BorrowRecord record = borrowRecordRepository.findActiveByBookId(bookId);
if (record == null) {
return ReturnResult.fail("未找到有效的借阅记录");
}
record.setReturnTime(LocalDateTime.now());
record.setStatus(Record
