ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot在线图书借阅平台实战:覆盖事务、并发与权限拦截

Spring Boot在线图书借阅平台实战:覆盖事务、并发与权限拦截 做过这个Spring Boot在线图书借阅平台之后我最大的感受是借阅系统听起来简单但真正动手做起来涉及的环节远比想象中多。用户注册登录、图书分类检索、借书还书、库存扣减、逾期计算哪个拎出来都能写一堆。如果你正在学Spring Boot想找一个能真正练手的技术项目而不是再做一遍“员工增删改查”这个项目非常合适。它既包含常规的CRUD也有事务控制、并发处理、权限拦截这些实际开发中躲不开的内容。这篇分享我会把整个设计思路、核心实现、以及我在实操过程中踩过的坑都写出来希望能帮你少走弯路。1. 项目整体设计与思路拆解1.1 项目定位与核心需求解析在线图书借阅平台核心要解决的事情有两件一是把图书馆的图书资源在线化让用户不用跑到线下就能查看书目和库存二是把借阅流程从线下手工登记搬到系统里做到有人借、有人还、逾期可查可罚。我把它定位成一个中小规模的业务系统主要角色分成两类管理员和普通读者。管理员负责图书录入、分类维护、查看所有借阅记录、处理异常订单读者可以注册登录、检索图书、发起借阅申请、查看自己的借阅历史、归还和续借。功能拆开来看大概有这些模块用户模块注册、登录、密码加密、会话管理、角色区分。图书模块图书基本信息书名、作者、ISBN、分类、封面、库存数量、在架状态。分类模块给图书做树状或单级分类方便筛选。借阅模块借书、还书、续借、借阅历史查询。逾期与罚款模块到期未归按天数累计罚款金额。后台管理模块管理员对图书和借阅订单的统一管理。这个项目比较适合作为综合练习项目是因为它天然要求你考虑多个表之间的关联比如用户和借阅记录是一对多图书和借阅记录也是一对多分类和图书是一对多。这种关联关系如果只在单表里做CRUD是感受不到的。同时借书这个动作会同时影响订单记录和图书库存这就是事务需求你再怎么绕也绕不开。1.2 技术选型背后的取舍技术栈我选了这样一套组合后端用 Spring Boot 2.7持久层用 MyBatis-Plus数据库用 MySQL 8.0前端用了 Thymeleaf 加简单的原生 JavaScript接口调试配合 Postman项目管理用 Maven代码简化用 Lombok。为什么选 Spring Boot没必要展开说它有多火。真正的原因是你不想花太多精力去折腾各种 XML 配置、框架间版本兼容而是想快速把业务跑起来。Spring Boot 的自动配置、项目启动器、嵌入式 Servlet 容器让“从零到能跑”这个过程变得非常快我就把主要精力留给了业务代码。持久层选 MyBatis-Plus 而不是 Spring Data JPA理由很实际。JPA 在复杂条件查询和动态 SQL 上依赖方法名推导和 JPQL遇到需要手写 SQL 的库存扣减场景反而不如 MyBatis-Plus 来得直观。MyBatis-Plus 内置了通用 Mapper对于单表 CRUD 几乎不用写 SQL而复杂查询可以直接写自定义 SQL。这个项目里库存扣减是核心操作必须用原子 SQL 来保证并发安全MyBatis-Plus 写起来非常顺手。前端没有上很重的框架因为我希望先把后端逻辑吃透。Thymeleaf 服务端渲染对初学者很友好数据由 Controller 塞进 Model页面直接取出来展示。虽然前后端分离是现在的趋势但在这个项目里如果一上来就搞 Vue ElementUI可能反而会分散注意力。真要做前后端分离到后期再拆也不迟。2. 核心细节解析与实操要点2.1 数据库设计从表结构到关联关系数据库我建了四张核心表用户表、图书表、分类表、借阅记录表。规范起见表名我用下划线字段也用下划线。用户表主要字段有id、username、password、role、create_time。图书表有id、book_name、author、isbn、category_id、stock、cover_url、status、create_time。分类表是id、category_name、parent_id可以支持两级目录。借阅记录表有id、user_id、book_id、borrow_date、due_date、return_date、status、fine_amount。这里我重点说一下几个设计决策。图书的stock字段表示当前可借库存不是总藏书量。每次借出时库存减一归还时加一这个字段必须是非负数在数据库层面可以用无符号整数配合代码判断。借阅记录里的status我取了几个值0表示借出中1表示已归还2表示已逾期3表示已续借。不要小看这个字段很多业务统计都要靠它。借阅记录里要不要直接冗余存书名和用户名我建议不要。借阅记录表只需要存user_id和book_id查询时再通过关联去取名称。冗余字段在早期好像方便但一旦书名变化或者用户改名冗余数据就变成脏数据。这个项目还没到需要做高性能读写分离的规模保留关联查询更合理。索引方面除了主键外我在借阅记录表的user_id和book_id上分别加了普通索引因为查询用户借阅历史和某本书被借情况是高频操作。用户表的username加唯一索引用于保证注册用户不重名。图书表在category_id上建索引方便按分类筛选。还加了一个组合索引(user_id,status)因为“查某用户当前未归还的借阅记录”这个操作很常见。2.2 后端接口设计的几个关键决策接口风格我走了 RESTful 路线但也没有完全死板到每个动作都要按 HTTP 动词来。比如/api/user/login、/api/user/register这类接口在语义上比硬塞一个 POST/api/users/login更清晰。统一返回结构也很重要否则前端对接时会出现每个人一套返回格式的混乱情况。我定义了统一的Result对象里面包含code、message、data三个字段成功时code200业务异常时code40001系统异常时code50000。接口清单大概是这样用户注册、用户登录、退出登录图书列表分页查询、图书详情、图书新增、图书修改、图书删除借阅接口的借书、还书、续借、查询我的借阅记录管理端的全部借阅记录查询。每个接口都做了入参校验用 Hibernate Validator 的注解比如NotBlank、NotNull避免参数错误被打到数据库才看出来。关于登录状态我选择了比较传统的 Session 加拦截器方案而不是 JWT。为什么因为图书借阅系统的前端和服务端是同一个域没有跨域、多端使用需求Session 实现简单而且天然支持服务端失效。拦截器统一处理未登录请求对于需要管理员权限的接口额外校验角色字段。统一异常处理也是必须做的。我定义了一个BusinessException当业务上出现问题比如“库存不足”“图书不存在”直接在 Service 里抛异常然后由RestControllerAdvice统一捕获转成Result返回。这样 Controller 层就不用到处写 try-catch代码会干净很多。2.3 业务逻辑层的实现与事务边界业务逻辑都写在 Service 层Controller 只做参数接收和结果返回。Service 层最重要的地方在于事务边界也就是Transactional标注的位置。借书这个动作涉及两步扣减库存和插入借阅记录。这两个操作要么都成功要么都失败否则就会出现“记录有了但库存没减”或者“库存减了但记录没有”的情况。我处理借书的核心逻辑是查图书是否存在、检查库存是否大于零、使用原子 SQL 扣减库存、插入借阅记录、设置借阅日期和应还日期。库存扣减的 SQL 写成update book set stock stock - 1 where id #{bookId} and stock 0这句 SQL 是并发安全的关键。如果直接 select stock再判断是否大于零然后 update在并发场景下会出问题。这一点我在后面的常见问题里会展开讲。事务边界上还有个小细节Transactional默认只在RuntimeException时回滚如果代码里手动 try-catch 了异常又没有往外抛事务就不会回滚。我在 Service 中避免自己 catch 业务异常而是直接抛出BusinessException让全局异常处理完成统一返回。还书的逻辑反之更新借阅记录状态为已归还设置实际归还时间同时给图书库存加一。如果借阅已逾期计算逾期天数乘以每天罚款金额写入fine_amount字段。3. 实操过程与核心环节实现3.1 从零搭建 Spring Boot 工程我是直接用 Spring Initializr 生成的项目依赖勾选了 Spring Web、MySQL Driver、Lombok、Validation另外手动在 pom.xml 里加入了 MyBatis-Plus 和 Thymeleaf。要注意 MyBatis-Plus 有专门的mybatis-plus-boot-starter不要误用成普通 MyBatis 的 starter否则自动配置会不生效。项目目录我按这样的结构组织src/main/java/com/example/library ├── common // 统一返回、异常处理、全局异常类 ├── config // 拦截器配置、MyBatis-Plus分页插件配置 ├── controller // 接口层 ├── dto // 接收前端参数的类 ├── entity // 数据库实体映射 ├── mapper // MyBatis-Plus Mapper接口 ├── service // 业务逻辑接口和实现 └── vo // 返回给前端的视图对象application.yml里配置了数据源、时区、日志级别、MyBatis-Plus 的驼峰映射。MySQL 连接串一定要加serverTimezoneAsia/Shanghai不然默认执行的时区可能和系统时间相差八个小时后面涉及借阅日期和逾期计算时你会非常痛苦。分页插件配置很简单MyBatis-Plus 的MybatisPlusInterceptor加上PaginationInnerInterceptor就能生效。不配置这个调用Page查询时会发现数据都能查出来但分页的总数始终不对这个坑我踩过所以提醒你早点配好。3.2 用户认证与权限实现用户注册这一步密码不能明文存用了 BCrypt 加密。Spring Security 里自带的BCryptPasswordEncoder可以直接用不过我没有引入整个 Spring Security只是单独引了spring-security-crypto在 Service 里手动调用加密。为什么不用明文数据库一旦泄露密码全暴露这个习惯必须养成。校验时用matches方法比对明文和密文不要自己去解密密文因为 BCrypt 本来就是不可逆的。登录成功后我把当前用户信息放进 Session以方便后续查询当前借阅人。在拦截器中实现登录校验继承HandlerInterceptor在preHandle里判断 Session 是否存在用户。如果没有跳转登录页或者返回统一错误提示。管理员接口的权限校验我在 Controller 接口上用注解标记角色再在拦截器里解析或者简单一点在方法入口判断当前用户角色是否是管理员。拦截器还需要注册到WebMvcConfigurer中并配置放行路径。登录、注册、图书列表查询这些接口不需要登录就能访问其余接口全部拦截。放行静态资源也很重要否则前端页面的样式和脚本加载不出来。3.3 图书借阅归还的完整流程实现借书逻辑是项目的核心直接上核心代码Transactional public void borrowBook(Long userId, Long bookId) { Book book bookMapper.selectById(bookId); if (book null || book.getStock() 0) { throw new BusinessException(图书不存在或库存不足); } int rows bookMapper.deductStock(bookId); if (rows 0) { throw new BusinessException(库存不足当前图书已被借走); } BorrowRecord record new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setBorrowDate(LocalDate.now()); record.setDueDate(LocalDate.now().plusDays(30)); record.setStatus(0); borrowRecordMapper.insert(record); }deductStock是写在BookMapper里的自定义方法Update(update book set stock stock - 1 where id #{bookId} and stock 0) int deductStock(Param(bookId) Long bookId);归还逻辑的核心代码Transactional public void returnBook(Long userId, Long recordId) { BorrowRecord record borrowRecordMapper.selectById(recordId); if (record null || !record.getUserId().equals(userId)) { throw new BusinessException(借阅记录不存在); } if (record.getStatus() 1) { throw new BusinessException(该图书已归还请勿重复操作); } LocalDate now LocalDate.now(); if (now.isAfter(record.getDueDate())) { long overdueDays ChronoUnit.DAYS.between(record.getDueDate(), now); record.setFineAmount(overdueDays * dailyFine); record.setStatus(2); } else { record.setStatus(1); } record.setReturnDate(now); borrowRecordMapper.updateById(record); bookMapper.increaseStock(record.getBookId()); }续借时要注意先判断当前记录有没有逾期如果逾期了就不能续借只能先归还再借。续借操作本质是把due_date在原有基础上增加三十天并把status改为3。3.4 前端页面与后端联调如果用 Thymeleaf 做页面主页面包括登录页、注册页、图书列表页、图书详情页、借阅记录页和管理后台。页面后端通过 Model 传数据前端用 Thymeleaf 模板语法遍历列表再配合一个简单的搜索表单把请求参数通过 URL 传到后端。但接口形式是 RESTful JSON所以我在页面上也用 fetch 调后端接口再渲染到 HTML 中。这样做的原因是我希望前端交互和后端接口保持清晰边界后续要把页面换成 Vue 也容易。比如图书列表页加载时在页面脚本里调用fetch(/api/books?page1size10)拿到 JSON 后动态拼接表格。联调的时候最容易被坑的是跨域问题。虽然服务端渲染没有跨域但如果你接下来拆分前后端需要在前端项目启用代理或者在后端加全局跨域配置。另外日期字段回传到前端时默认的LocalDateTime序列化是一长串数组必须加上JsonFormat注解或者统一配置 JavaTimeModule否则前端展示日期会非常抽象。接口联调完成后建议用 Postman 把整个流程过一遍注册新用户、登录、管理员添加图书、用户借书、用户还书、管理员查看借阅列表。我在项目开发期就是靠这个闭环流程来验证每一步逻辑比写单元测试的“仪式感”更直接。4. 常见问题与排查技巧实录4.1 并发借阅导致的库存超卖我最初实现借书功能时用的是“先查库存再减库存”的方式。单人使用时没问题但我用两个账号同时模拟借最后一本书结果两条借阅记录都插入成功了库存变成了负数。这个问题的根因是查询和更新不是原子操作两个请求读到相同的库存值然后各自减库存最后库存数据就错了。解决办法就是在 SQL 层直接加上库存大于零的条件用更新结果的行数判断是否借阅成功。这样即使两个请求同时执行数据库中只有一条更新能匹配到stock 0另一个更新的受影响行数是零直接抛出库存不足。这个思路在秒杀系统里会加乐观锁但在这里原生 SQL 已经足够可靠。另外数据库字段如果允许负数最好在 ddl 中定义stock为int unsigned数据库层面再加一道防线。4.2 事务失效的坑事务失效最常见的是同一个类内部调用加Transactional的方法。比如我在一个BorrowService里写了一个doBorrow方法又写了一个公开方法batchBorrow在batchBorrow内部直接调用doBorrow。表面上看起来这个私有方法也有事务注解但实际上 Spring AOP 不会拦截同一个类内部的方法调用事务直接失效。解决方式是把需要事务的方法放到另一个 Service 类中或者通过注入自己的代理对象来调用。另一个坑是Transactional遇到 try-catch。很多人为了不让系统异常弹出来在 Service 方法内部用 try-catch 包住异常再吞掉结果事务没有触发回滚数据写到一半就停在那里。我的经验是不要在事务方法里吞异常让它抛出去由全局异常处理器统一处理。4.3 日期时间处理的那些坑日期处理在这个项目里翻车了很多次。一个是连接 MySQL 时没有指定时区导致LocalDate存入数据库后差一天。另一个是前端展示时把LocalDateTime序列化成了乱七八糟的数组格式。处理建议是项目里涉及“哪天借、哪天还”这类业务统一使用LocalDate而不是带时分秒的LocalDateTime因为借书不需要精确到时分。数据库字段类型用date字符串类型连接串加serverTimezoneAsia/Shanghai。前端展示时后端返回的数据直接是一个 ISO 格式的字符串不做多余转换。逾期天数的计算也是一个容易出错的地方。如果直接用“当前时间 - 应还时间”取毫秒数再除以一天的毫秒数碰上夏令时或时间戳精度问题容易差一天。我用的是ChronoUnit.DAYS.between(dueDate, now)基于日期粒度比较不会出现这种偏差。4.4 分页查询与前端渲染问题图书列表和借阅记录都需要分页。MyBatis-Plus 的Page对象在配置了分页插件后会自动执行COUNT和LIMIT两条 SQL。但有个细节返回给前端时最好封装成自定义PageResult对象包含total、records、pageNum、pageSize四个字段而不要直接把 MyBatis-Plus 的Page实体暴露给前端避免把无关的分页字段带出去。前端分页渲染时我第一次犯的错是根据返回值里的current字段回显当前页但接口里没返回这个字段导致点击第二页时页数一直跳回第一页。解决方式就是在PageResult里统一加好当前页和总页数。另外一个容易忽略的问题是分页插件需要配置数据库方言。好在我用的 MySQL默认能自动识别。如果你切到 PostgreSQL记得加对应的方言配置。4.5 最后的几招排坑心得系统做完之后我自己总结了几个非常实用的排查技巧。第一想定位接口问题时先把后端日志级别调到debug看入参、SQL 的执行情况、返回值绝大多数问题都能在这里发现。第二前端报错时先看 network 面板查看具体返回状态码和响应内容很多时候是参数格式不对而不是后端逻辑问题。第三用 Postman 做完整流程回归测试每次改完代码就把主要流程跑一遍能有效避免改了一个功能把另一个功能带崩。还有一点是我个人很看重的写方法名和注释要表达业务含义。比如deductStock比updateStock更清楚findActiveBorrowRecords比selectList更明白。这个项目虽然不算大但代码结构清晰能让你在两周后再打开时快速回想起当时的思路。不要为了省时间不写注释至少在每个 Service 方法上方写清楚“做什么、什么情况下失败、失败后怎么处理”。从最初的需求分析到数据库设计再到借阅流程的实现整个过程下来最能锻炼人的其实是边界情况的处理。你不仅要考虑“正常借书还书”还要考虑“书全部借完了怎么办”“用户逾期了还能续借吗”“同一个用户能不能同时借同一本书多本”。这些问题的答案写代码之前想清楚后面就会顺畅很多。如果你也打算做这个项目建议先把流程画出来再动手写第一行代码。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进