无人共享图书借阅平台:Spring Boot实现与架构设计

无人共享图书借阅平台:Spring Boot实现与架构设计 1. 无人共享图书借阅平台为什么要做解决什么问题说起共享图书借阅很多人第一反应是不就是个图书管理系统嘛。但无人两个字把难度提升了一个档次——没有管理员值守所有借书、还书、续借、超期处理都要靠用户自助完成。这意味着系统必须在没人盯着的情况下自己判断这本书能不能借这个人有没有资格借他还回来的书是不是原来那本而且决策错了没有人帮你兜底。今年我花了大概三周时间从零到一搭了一个完全基于Java技术栈的无人共享图书借阅平台。这个平台面向的场景是社区书吧、园区自助书角、学校走廊书柜这类中小型场景核心目标是让用户扫个码就能完成借书还书运营方在后台能看到每本书的状态和借阅记录。先说下这套系统的技术选型后端用Spring Boot 2.7 MyBatis Plus MySQL 8.0 Redis前端管理后台用Vue 3 Element Plus用户端做的是微信小程序。整套代码我放在了GitHub上最近有不少网友在评论区问架构设计和核心流程的实现思路所以写了这篇博文把关键模块的落地细节讲清楚。适合谁来读这套源码如果你是准备Java面试的在校生这份代码里包含了一套完整业务系统的所有标准件用户体系、权限控制、订单流转、库存一致性处理、消息通知面试聊项目经验完全够用如果你是刚转Java不久的初级开发想看看真实项目的表结构设计和并发处理思路也值得花一个周末通读一遍就算你只是对无人值守场景感兴趣看完这篇也能理解一套自助业务系统从扫码到履约的完整链路。这周我还专门去问了一圈GitHub上star和fork这套代码的朋友发现大家问得最多的是两个问题一是不明白为什么借阅状态要拆成那么多字段二是不理解还书时如果用户没预约怎么知道该放回哪个格子。这两个问题恰恰是无人借阅系统区别于普通图书管理系统的核心所在后面我会重点展开。2. 整体设计与模块划分先理清业务闭环再动手写代码2.1 无人借阅平台的业务链路动工之前我先把整个业务场景走查了一遍。一个用户来到无人书柜前他的操作路径大概是这样的扫码打开小程序 → 浏览书架/搜索书 → 看到想借的书 → 确认借阅 → 柜门/格口解锁 → 取书 → 系统记录借阅成功。还书路径则是扫码 → 选择我要还书 → 格口开启 → 放入图书 → 系统确认归还。这个链路跟传统图书管理系统最大的区别在于所有操作都依赖用户主动触发 系统自动确认没有管理员在中间环节做校验。比如传统系统里管理员可以肉眼确认用户还回来的书没有破损但无人场景下这个动作没法做只能靠用户自觉和事后追溯。基于这个链路我把系统划分为四个核心模块用户端小程序负责注册登录、图书检索、借阅/续借/预约、我的借阅记录管理后台负责图书上下架、库存管理、用户管理、借阅记录查询、超期罚款处理核心服务层负责借阅流程状态流转、库存一致性处理、超期任务调度、信用积分计算硬件对接层负责跟智能书柜/格口控制器通信下发开锁和关锁指令这套划分标准是按职责域拆分。硬件对接单独抽一层是因为市面上书柜厂商的通信协议五花八门有的走HTTP接口有的走TCP长连接有的是MQTT订阅。如果把这些逻辑散落在业务代码里后面换设备厂商就要改一堆业务代码抽出来之后只需要替换适配器实现。2.2 关键状态机设计无人借阅系统最核心的是状态设计。一本书从入库到报废中间会经历多个状态我把它定义为在架可借、被预约、借出中、归还待上架、下架维护、丢失报损。为什么除了在架可借和借出中还需要其他状态因为无人场景下存在大量中间态。比如用户在小程序里预约了一本书但还没来取这时候书要在架子上留着但其他用户不能借走再比如用户还书后书放在了归还格口里没有管理员及时把它放回书架此时它在系统里既不在架也不借出需要一个独立的待上架状态来标识。借阅记录的状态我单独建了一张表字段包括待取书、借阅中已取出、已归还、已续借、已超期、已违约。这里有个设计要点图书状态和借阅记录状态必须分开存储。因为同一本书在不同时间段会被不同用户借阅每个借阅周期都有独立的生命周期而图书本身的状态是全局唯一的。如果只用一个字段去表达要么丢失历史记录要么没法表达当前库存。2.3 为什么用Spring Boot MyBatis Plus而不是别的组合选型的时候我确实纠结过一阵子。团队里有人建议用Spring Cloud全家桶有人建议用JPA还有人建议直接用PHP写个快的。最后我定了Spring Boot 2.7 MyBatis Plus理由其实很朴素第一这套系统是典型的单机事务型业务不需要微服务的分布式能力。Spring Cloud那些服务注册、配置中心、熔断降级放在这个场景里就是过度设计白白增加部署和排查复杂度。第二MyBatis Plus在CRUD场景下的开发效率比原生MyBatis高很多代码生成器可以直接生成实体、Mapper、Service省掉大量样板代码。第三国内Java生态里MyBatis Plus的受众面很大一些面试场景里聊到ORM框架时也好展开。版本上我特别选了Spring Boot 2.7而不是3.x原因很实际3.x要求JDK 17但很多企业生产环境还在用JDK 8。如果你想把这套代码跑在现有服务器上2.7兼容性要好得多。Redis用的Spring Data Redis默认配置缓存key设计上注意区分了图书维度和用户维度避免缓存穿透和雪崩。3. 数据库设计与核心表结构每一张表的设计理由3.1 六张核心表的字段拆解这套系统的数据库一共八张表核心六张分别是用户表、图书表、库存表、借阅记录表、预约记录表、罚金记录表。下面我挑关键表详细拆一下字段设计思路。用户表user除了常规的id、nickname、phone、avatar、create_time之外我加了两个字段credit_score信用分和status账户状态。信用分初始100分每次超期还书扣10分丢书扣50分连续三次超期自动冻结借阅权限。这里加信用分机制是为了在无人场景下做软约束——没有管理员当面警告系统只能靠积分规则来调节用户行为。图书表book的字段值得说道说道。除了书名、作者、ISBN、分类、封面图之外我专门加了bind_code字段这个字段是把同一本书的不同物理副本区分开的关键。比如《深入理解Java虚拟机》这本书库里有三本它们的ISBN是一样的但bind_code分别是JV001、JV002、JV003。用户扫码借书时扫的是贴在具体某一本书上的条码或二维码系统通过bind_code定位到具体副本而不是笼统地扣减一个库存数字。库存表book_stock实际上是把图书的静态信息和动态状态拆开了。stock_id关联book_id字段包括total_count、available_count、borrowed_count、reserved_count、shelf_location柜门编号。每次借书还书操作这四个数字同步变更。这里必须强调这四个数量字段不是冗余存储而是为了承担并发校验的职责。MyBatis Plus的乐观锁插件可以直接在这个表上做版本控制防止两个用户同时借走同一本书。借阅记录表borrow_record是系统里最核心的表。字段包括record_id、user_id、book_id、bind_code、borrow_time、due_time、return_time、status、renew_count。我特意把bind_code冗余在这个表里而不是通过book_id再去关联这样查询历史记录时少一次联表也方便锁定某一本具体书的借阅轨迹。due_time是应还时间由borrow_time加上借阅周期算出比如默认30天管理员可以在后台调整。3.2 为什么用户身份要绑定微信OpenID用户端做的是微信小程序所以用户表里我加了openid字段并且建了唯一索引。用户第一次打开小程序时前端调用wx.login拿到code后端通过code换openid。如果openid在用户表里不存在则自动创建账号存在则直接登录。这相当于用微信身份做了一种免密登录用户不需要单独注册账号和密码。这里有个细节不要直接用openid作为业务主键。openid是微信侧的标识虽然理论上唯一但后续如果平台要接入支付宝小程序或者其他渠道用户身份标识会出现多对一的问题。所以用户表还是用自增id做主键openid只是作为登录凭证。3.3 借阅时间边界怎么处理才算合理借阅周期和超期判断是无人系统里最容易踩坑的地方。我一开始直接用borrow_time 30天算due_time然后每次请求时拿当前时间和due_time比较。后来上线跑了一周发现一个问题用户当晚23:59还书系统因为跨秒判断成了超期。处理方式是把时间精确到天而不是秒。due_time记录的是yyyy-MM-dd 23:59:59超期判断只看日期不看时分秒。用户只要在应还日期当天24点前操作还书都不算超期。这个逻辑在业务上更符合直觉也减少了一大批边界投诉。另外还书时间要取服务器时间而不是用户手机时间。用户改手机时间躲超期这种事在无人场景下是真实会发生的所以后端所有时间相关逻辑必须统一用服务器当前时间。4. 借阅流程的完整实现从扫码到履约的关键环节4.1 借书主流程预扣库存、创建记录、下发开锁指令借书的完整流程我在代码里做了一个专门的状态机处理器核心步骤如下第一步用户在小程序端确认借阅某本书前端把book_id和bind_code传给后端。第二步后端先做资格校验。用户账户状态是否正常信用分是否大于60当前借阅中数量是否达到上限默认5本这本书当前状态是否在架可借。任何一个校验不通过直接返回错误原因。第三步锁定库存并创建借阅记录。这一步是关键中的关键。我先对book_stock表执行一条UPDATE语句UPDATE book_stock SET available_count available_count - 1, borrowed_count borrowed_count 1, version version 1 WHERE book_id ? AND available_count 0。用乐观锁和可用数量条件双保险确保不会超卖。更新成功后再insert一条borrow_record状态为待取书。这里为什么必须先锁库存再创建记录因为如果顺序反了可能出现两个用户同时提交借阅请求都通过了库存校验但只有一本库存可用的情况。把库存扣减做成原子操作后面的创建记录只是记录事实不再是决策依据。第四步调用硬件服务层下发开锁指令。格口编号存在book_stock的shelf_location字段里。硬件服务层根据设备厂商协议组装指令发送成功后返回一个token记录到借阅记录表的unlock_token字段。第五步等待硬件回调确认取书。这里有个设计取舍——开锁后用户可能没取书就走了怎么办我的方案是开锁后15分钟内如果系统没有收到取书成功回调自动把这个格口重新锁定并把借阅记录状态回滚为已取消库存同步恢复。这个逻辑通过一个延迟任务实现后面会详细讲。4.2 还书主流程确认绑定、回收库存、校验归还状态还书流程跟借书流程在逻辑上是镜像的但有几个细节不同。用户还书时选择小程序里的我要还书然后扫书上的条码。系统根据bind_code找到对应的进行中的借阅记录校验当前用户确实是借阅人然后下发开锁指令让用户把书放进指定的空格口。这里有个常见问题用户可能没有保存原书的条码或者书上的二维码磨损了扫不出来。所以我设计了两种还书方式扫码还书和手工输入bind_code还书。手工输入时前端做一次格式校验防止用户乱输。书放进格口后系统需要做一次归还校验。如果书柜自带RFID识别能力可以直接从RFID读到书ID跟预期归还的bind_code比对如果只是普通扫码柜那就只能默认用户放进去的书就是他要还的那本。考虑到成本我的实现里默认走的是普通扫码柜方案但硬件对接层预留了RFID校验的接口有条件的场景可以无缝升级。校验通过后更新borrow_record状态为已归还更新return_time为当前时间扣减borrowed_count增加available_count。同时触发一个异步任务计算本次借阅是否超期如果超期则自动生成罚金记录并从用户信用分里扣除对应分值。4.3 预约与取书防止约了不来的策略预约流程也不复杂用户预约某本书后系统先把这本书状态从在架可借改为被预约再创建一条预约记录。预约有效期为24小时超时未取自动释放书回到在架状态。到了这一步有个策略问题预约成功后需要通知用户来取书同时书还在书架上。用户来取书时怎么定位到那本书我的做法是取书时用户在小程序里点取预约书系统展示这本书的bind_code和格口位置用户找到书后扫条码确认然后系统下发对应格口的开锁指令。这里没有用自动开锁自动定位的智能方案因为成本太高但流程上完全跑得通。为了避免约了不来占用资源我在预约表里加了一个预约失效任务每半小时扫描一次把超过24小时未取书的预约记录标记为已失效同步把图书状态改回在架可借。这个任务用Spring的Scheduled注解实现固定延迟30分钟执行一次。4.4 超期任务与信用分计算的定时实现整个项目里最让我花心思的是超期任务调度模块。最初我用的是Spring自带的Scheduled注解写了一个定时任务每天凌晨两点扫描所有状态为借阅中且due_time小于当前日期的记录把状态改成已超期生成罚金记录。后来想了想发现一个问题如果用户借了30天正好在第31天凌晨还书但定时任务在凌晨两点已经跑过了系统会先判超期生成罚金用户随后还书时又发现已经生成罚金记录两边状态对不上。为了解决这个问题我把是否超期设计成实时计算逻辑而不是依赖定时任务的状态翻转。具体实现是当用户还书时后端拿到return_time实时跟due_time比较如果return_time due_time则当场生成罚金记录。定时任务只负责扫描并标记长期未还的异常书籍比如超过应还日期7天还没动静的系统自动发送提醒消息给用户并把该用户的借阅权限临时冻结。这样职责清晰不会出现重复扣费的问题。罚金计算规则每天0.5元不足一天按一天算。罚金记录表里有一个status字段标记待支付已支付已豁免。用户缴纳罚金后系统自动恢复信用分扣除的信用分每个自然月最多恢复10分。5. 实操中的并发处理与数据一致性保障5.1 乐观锁在库存扣减中的应用借书的高并发场景下两个用户同时抢同一本书如果代码是先查询库存再判断再扣减大概率会出现超卖。我在网上看过不少Java项目源码很多人在这个环节用的是synchronized加锁实际上单机部署还行一旦水平扩展就失效了。我的方案是数据库层面的乐观锁。在book_stock表加了一个version字段更新时带着旧的version作为条件如果更新影响行数为0说明version已经被其他事务修改本次扣减失败返回手慢了这本书刚被别人借走的提示。这里有一个细节UPDATE语句的条件里既要带version又要带available_count 0。version保证不丢更新available_count保证逻辑上还有库存两个条件缺一不可。这种方案在绝大多数中小场景下已经完全够用。只有当并发量达到每秒几百次以上的时候才需要考虑Redis分布式锁或者消息队列削峰图书借阅场景根本到不了这个量级。5.2 分布式锁在生成借阅编号中的使用借阅记录表的主键是自增id但业务上需要展示一个用户可读的借阅编号格式类似BORROW20240601001。这个编号我通过Redis的INCR命令生成key按照日期划分borrow:no:20240601。每天从1开始递增拼接日期就是唯一编号。这里为什么不直接用数据库自增id两个原因一是自增id很容易被遍历用户可能通过修改id参数查询别人的借阅记录二是借阅编号在用户端和客服沟通时需要口头报读短编号比长id更友好。Redis INCR天然是原子操作不需要额外加锁。但要注意设置过期时间不然key会无限增长。我在代码里设置的是两天过期跟定时任务清理策略配合确保不产生内存泄漏。5.3 数据库事务边界的设计借书流程涉及多个表的写操作扣减库存、创建借阅记录、更新用户借阅数量、下发开锁指令。这些操作必须放到同一个事务里但硬件开锁指令不能放到事务里。为什么因为调用第三方接口存在网络超时的可能如果开锁调用失败整个事务回滚库存和记录都回滚了但硬件端可能已经把锁打开了用户能拿到书但系统不承认这就是典型的分布式事务不一致问题。我的方案是事务只包裹纯数据库操作扣库存创建记录开锁指令放在事务提交成功之后的事务同步回调里执行。如果开锁调用失败记录状态仍然保留但增加一个待处理标记定时任务会重试开锁。如果重试3次仍然失败则自动取消借阅记录并恢复库存。这套事务内做数据准备、事务外做外部调用的思路在很多实战项目中都是通用的套路。5.4 缓存设计与热点数据保护用户在首页看到的图书列表、每本书的详情这些数据读多写少适合用Redis做缓存。我的缓存key设计思路book:detail:{bookId}value是JSON序列化后的图书详情。缓存设置了30分钟过期每次借书还书操作后主动删除对应缓存而不是等它被动过期。这里有个小坑如果直接删缓存再更新数据库并发场景下可能有一个旧请求把旧数据写回缓存。我的做法是先更新数据库再删缓存通过Cache Aside Pattern保证最终一致。数据库操作在事务里事务提交成功后执行删除缓存操作这样即使删除失败最坏情况只是缓存比数据库旧一点但不会出现数据错乱。首页列表的缓存我做了一个整页缓存的优化把首页推荐书目的JSON直接缓存key是book:homepage:recommend设置5分钟过期。因为首页数据变动不频繁这样能扛住活动期间的突发流量。6. 常见问题与排查技巧实录6.1 用户扫码后提示图书不存在怎么办这个问题踩过坑。当时排查发现用户扫的是书背面的ISBN条码而不是系统生成的bind_code二维码。我在代码里对条码做了自动识别如果扫描内容匹配ISBN格式则从book表反查所有该ISBN对应的绑定书副本如果匹配bind_code格式则直接定位到具体副本。但在界面上两种方式的提示文案完全不同。扫ISBN时如果有多个副本提示用户选择具体哪一本如果只有一个副本直接展示确认信息。处理这个逻辑的关键是写一个解析方法根据条码内容的格式规则区分类型不能上来就按bind_code查库。6.2 还书后库存没有恢复的排查思路这个问题的原因通常是还书流程里的某个异步环节出了问题。我的排查路径是先看borrow_record的return_time有没有写入再看book_stock的borrowed_count有没有扣减最后看异常日志里有没有硬件回调相关的报错。有一次我发现某个还书请求的事务一直卡住查日志发现是数据库连接池的连接被占满了。原因是硬件回调接口没有设置超时时间某个设备一直不响应导致线程池里的线程全部阻塞在等待回调上。解决方案是所有外部接口调用必须设置连接和读取超时我用的是3秒并且用线程池隔离硬件调用不占Tomcat的工作线程。6.3 预约书被借走的并发异常预约功能上线后收到一个bug反馈用户预约了一本书到现场发现书被别人借走了。查代码发现我是在确认借阅接口里才校验图书状态而不是在创建预约时锁定。两个用户同时预约同一本书系统都创建了预约记录先到的用户借走了书后到的用户拿着预约凭证取不到书。修复方案是创建预约时就用乐观锁更新图书状态从在架可借改成被预约更新成功才允许创建预约记录。同时借书接口也要加一道校验如果图书处于被预约状态且预约人不是当前用户直接拒绝借阅。这样既防止重复预约也防止非预约人抢走预约书。6.4 定时任务重复执行导致的数据问题超期扫描定时任务有一个隐患如果部署了多个实例每个实例的Scheduled都会触发造成重复处理。我在代码里加了一个简单的分布式锁用一个Redis key表示超期扫描任务正在执行如果key存在则直接跳过本次执行执行完毕后删除key并设置过期时间防止任务异常退出导致锁无法释放。这个方案虽然不是重量级分布式锁但对这种低频率的定时任务已经足够。如果你想更严谨可以用Redisson的分布式锁或者直接用ShedLock框架原理都是类似的。6.5 印象最深的一个坑事务里调用远程接口写代码初期我在借书事务里直接调用了开锁接口。看起来没什么问题但有一次模拟故障时发现开锁接口响应超时了整整30秒数据库事务一直不提交把连接池占满了。从数据库监控可以看到大量sleep状态的连接应用随即出现雪崩。这次事故让我彻底明白了远程调用不能放在本地事务里这个原则。后来我再写类似代码时会刻意检查当前方法是否处于事务中如果是那所有远程调用必须挪到事务外或者改用消息队列异步处理。希望看到这篇博文的朋友能直接避开这个坑。7. 项目源码结构与扩展方向7.1 源码目录结构与模块依赖关系项目采用Maven多模块结构总共四个模块borrow-common通用工具类、统一返回结果、异常枚举、常量定义borrow-system核心业务逻辑用户、图书、借阅、预约、罚金borrow-admin管理后台接口层配合Vue管理端使用borrow-hardware硬件设备适配层包含智能书柜的抽象接口和实现模块间的依赖关系是borrow-admin和borrow-hardware都依赖borrow-systemborrow-system依赖borrow-common。这样做的好处是硬件实现可以独立替换管理后台和用户端共用同一套核心业务服务。7.2 从这份源码延伸到Java面试的知识点很多读者问我这份源码里有哪些知识点可以拿到面试中去聊。我梳理了一下第一乐观锁和数据库事务。面试官问如何防止超卖你可以把借书流程里扣减库存的SQL写给他看解释为什么用version字段而不是synchronized。这是高并发场景的经典问题。第二缓存一致性的Cache Aside Pattern。你可以讲借书后删除缓存的操作以及为什么是先更新数据库再删缓存。面试官通常还会追问缓存穿透、击穿、雪崩你可以结合图书列表的场景聊布隆过滤器、互斥锁、过期时间随机化。第三定时任务的幂等性处理。把超期扫描任务的Redis分布式锁方案讲清楚顺便提到ShedLock的用法面试官会认为你真的处理过生产环境问题。第四外部接口调用的异常处理。把远程调用不能放事务里这个案例讲出来透露出你踩过坑并且分析过原因这种真实经历比背八股文有说服力得多。7.3 后续可以扩展的方向这套系统目前能跑通完整的借还闭环但离完全无人化还有距离。我列了几个可以继续扩展的方向一是接入人脸识别。用户站在书柜前刷脸系统自动完成身份识别和借阅记录关联连手机都不用掏。技术上可以对接第三方人脸识别API或者用本地的OpenCV方案。二是智能推荐。基于用户的借阅历史用协同过滤算法推荐相似书籍。这个功能不需要特别复杂的算法简单的物品相似度计算就能出效果。三是多设备联动。如果一个点位部署多台书柜需要记录每本书具体存放在哪台设备的哪个格口这套系统需要增加设备管理表和格口状态表对现有数据结构做一次扩展。四是数据可视化大屏。通过对接ECharts展示实时借阅数据、热门图书排行、各时段借阅量趋势这些对运营方很有价值实现起来也不复杂。总的来说这套代码的定位是一套能跑通的完整业务系统而不是某个技术点的demo。它的价值在于把真实业务场景里的各种细节都暴露出来了。我写完这套系统最大的感受是写代码不难难的是把所有异常场景都考虑到并且保证出问题时有兜底方案。希望这篇博文能帮到你也欢迎在GitHub上提issue交流。