ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

图书租借系统开发实录:Spring Boot与MyBatis-Plus实现借阅归还全流程

2026/9/15 22:54:52 拓冰建站 浏览量
图书租借系统开发实录:Spring Boot与MyBatis-Plus实现借阅归还全流程 如果你正在为“图书租借系统”这种毕设题目发愁大概率已经翻了不少博客、下载了好几份源码结果打开项目一看要么数据库就一张表根本没有借阅和归还的完整闭环要么代码还是十年前 SSH 那套光配置 XML 就能劝退一半人。这篇博客就把我做这套基于 Java 的图书租借管理系统时踩过的坑、想清楚的业务逻辑、以及最后怎么把“借阅与归还管控”这条主链路做得能看、能讲、能答完整拆给你看。这套系统不是什么高并发分布式大项目但作为毕业设计它的核心价值在于“业务闭环完整 技术栈主流 演示效果好”。我做的时候选型是 Spring Boot MyBatis-Plus MySQL Thymeleaf前端用了 Bootstrap 搭后台管理界面。整个过程下来最大的体会是图书租借系统的难点不在“增删改查”而在借阅状态管理、逾期费用计算、库存与在借数量的同步以及如何把这些逻辑讲得让答辩老师觉得你有工程思维而不是只会粘代码。无论你是打算自己从头写还是想搞清楚别人给的源码到底怎么回事这篇都可以当一份“解毒文档”用。1. 先把业务边界划清楚这个系统到底要管哪些事很多人在拿到“图书租借系统”这个题目后第一反应是疯狂堆功能搞个用户积分、搞个图书评论、再搞个消息推送恨不得做成豆瓣 图书馆管理系统的合体。我劝你先冷静。毕设打分看的是“核心功能是否完整、逻辑是否自洽、技术点是否有深度”不是看功能清单有多长。1.1 核心业务链路拆解从上架到归还的完整闭环我把这套系统的业务链路划成了这样一段闭环这也是答辩时最值得展开讲的“业务主线”图书管理员把图书信息录入系统设置馆藏数量用户读者注册登录检索图书用户发起借阅申请系统检查“库存余量”和“用户当前未还数量”借阅成功后图书的“可借数量”减少“在借数量”增加用户归还图书系统计算是否逾期逾期则生成费用记录用户支付逾期费用或管理员标记已处理图书状态恢复为可借这条链路看起来简单但每一步都隐藏着业务规则。比如“库存余量”不是简单的“总数量减去借出数量”还要考虑“图书是否下架”“是否被预约占用”“用户是否有未缴清的逾期费用”。我在设计时就把这些规则收敛到了 Service 层而不是散落在 Controller 里答辩的时候你就可以说“我把业务规则统一收敛到 service 层避免 Controller 过于臃肿也方便做单元测试。”这一句话就比你贴十行 CRUD 代码管用。1.2 角色权限设计管理员和普通用户到底有什么区别角色权限这块我不建议一上来就上 Spring Security JWT 做一套完整的权限模型因为毕设周期有限而且如果前后端不分离Session 拦截器就足够讲了。我设计了两种角色角色权限范围管理员admin图书新增/编辑/上下架、用户列表查看、借阅记录的查询与强制归还、逾期费用处理、统计报表普通用户reader注册登录、图书检索、发起借阅、查询自己的借阅记录、续借、归还实现上我没有引入 Spring Security而是写了一个简单的拦截器HandlerInterceptor基于 Session 里存的用户角色做放行判断。这样做的好处有两个一是代码量小逻辑直观答辩时你能手撕源码二是可以顺便讲清楚“拦截器、过滤器、AOP 三者的区别”这种高频面试题。后续如果你想升级可以在不改变业务代码的前提下把拦截器替换成 Spring Security JWT这种“可演进”的设计思路在答辩时也是个加分项。2. 技术选型为什么是 Spring Boot MyBatis-Plus而不是传统 SSM图书租借系统在 CSDN 上能找到的旧项目十有八九是 JSP Servlet JDBC 或者 SSMSpring SpringMVC MyBatis加 XML 配置那一套。如果你只是想要一份能跑的代码那无所谓但如果你想在答辩时把“为什么这么选型”讲明白我强烈建议换到 Spring Boot。2.1 从维护成本角度聊聊选型逻辑我对选题的要求是代码要少但技术点不能浅。Spring Boot 最大的价值不是“新”而是把 Spring 家族繁琐的 Bean 配置、事务配置、数据源配置全收敛成了“约定大于配置”这对毕设这种短周期项目来说是决定性的。我一开始也纠结过要不要用 SSM毕竟是经典组合老师也熟。但后来算了一笔账SSM 光 spring.xml、springmvc.xml、mybatis-config.xml 三个配置文件加起来就有几百行还要处理 jar 包冲突Spring Boot 只需要一个 application.ymlMyBatis-Plus 还能把单表 CRUD 的 Mapper 接口直接省掉大半。如果你要用 MyBatis-Plus记住一个关键点它解决的不是“复杂查询”的问题而是“简单 CRUD 写烦了”的问题。图书、用户、借阅记录这三张表的增删改查用 MyBatis-Plus 的 BaseMapper 接口可以直接继承连 SQL 都不用写。真正需要手写 SQL 的只有那些多表关联统计比如“逾期次数最多的用户 Top10”“图书借阅排行榜”这类报表需求。2.2 项目分层与包结构设计直接影响答辩观感包结构这件事看起来不起眼但答辩老师打开你项目的第一眼看的就是这个。一个好的包结构能让人快速判断你有没有工程经验。我最终用的包结构是这样的com.library ├── controller # Web 层接收请求、参数校验、返回视图或 JSON ├── service # 业务层事务边界在这里控制 │ └── impl ├── mapper # MyBatis-Plus 的 Mapper 接口 ├── entity # 数据库实体类 ├── dto # 数据传输对象比如借阅请求参数、统计结果 ├── vo # 视图对象比如页面展示用的图书信息含在借数量 ├── config # 配置类比如拦截器注册、MyBatis-Plus 分页插件 ├── common # 通用返回结果、异常处理、常量类 └── interceptor # 登录与权限拦截器这里有个细节需要注意entity 和 vo 不要混用。比如图书表里的“库存总量”和“在借数量”是数据库字段页面上还要显示“可借数量”通常是计算出来的直接往 entity 里塞一个“可借数量”字段也能跑但严格来说这属于视图层的数据需求应该放到 vo 里。分开之后答辩时你可以顺带提一句“我遵循了领域模型与表现模型分离的思想”这比你解释半天业务逻辑更有说服力。2.3 数据库版本与字符集、时区相关的坑MySQL 我用的 8.0这里提前说两个坑连接串必须带serverTimezoneAsia/Shanghai否则驱动会报时区错误。数据库和表的字符集要统一用utf8mb4别用utf8否则用户昵称里一旦有 Emoji 表情入库直接报错或变成乱码。不过 utf8mb4 的排序规则建议用utf8mb4_general_ci就行不需要上utf8mb4_0900_ai_ci除非你有特殊需求否则省点性能。3. 数据库设计图书、用户、借阅记录三张核心表怎么建模数据库设计这部分我真是见过太多反面教材。最常见的错误是把“借阅记录”表设计成只有 book_id、user_id、borrow_date、return_date 四个字段连状态都没有想查“谁还没还书”都写不出 SQL。我分享一下经过实际测试后比较稳的一套表结构。3.1 图书表不要只存书名和作者图书表除了基本信息外还必须有“总量”和“在借数量”两个字段。注意我是用“在借数量”而不是用“剩余数量”原因后面在借阅并发控制里会说。CREATE TABLE book ( id bigint NOT NULL AUTO_INCREMENT, isbn varchar(32) DEFAULT NULL COMMENT ISBN号, book_name varchar(200) NOT NULL COMMENT 书名, author varchar(100) DEFAULT NULL COMMENT 作者, publisher varchar(200) DEFAULT NULL COMMENT 出版社, category varchar(50) DEFAULT NULL COMMENT 分类, total_count int NOT NULL DEFAULT 1 COMMENT 馆藏总量, borrowed_count int NOT NULL DEFAULT 0 COMMENT 当前在借数量, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1上架 0下架, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_book_name (book_name), KEY idx_category (category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图书表;borrowed_count这个字段是典型的“冗余字段”因为理论上它可以由借阅记录表统计出来。但在这里冗余是值得的借阅列表页、图书详情页大概率都要展示“在借数量/可借数量”每次都 count 借阅记录表会在数据量上来后变得很慢。牺牲一点一致性换取查询性能这在业务上是可以接受的。3.2 用户表与逾期费用处理用户表我沿用了标准的用户模型id、username、password、real_name、phone、role、status、create_time。密码一定要存 BCrypt 加密后的密文别用 MD5。虽然这是毕设但答辩老师问到“你这个密码安全吗”能答上 BCrypt 加盐哈希比支支吾吾强太多。逾期费用的处理我单独建了一张表没有在借阅记录表里加一个 payment_status 字段了事。因为一个借阅记录可能产生多次费用调整比如管理员减免、用户补缴单独建表才能保留完整的操作轨迹。这在答辩时也是可以讲的“审计日志”思路。CREATE TABLE overdue_fine ( id bigint NOT NULL AUTO_INCREMENT, borrow_id bigint NOT NULL COMMENT 关联借阅记录ID, user_id bigint NOT NULL, fine_amount decimal(10,2) NOT NULL COMMENT 罚款金额, status tinyint NOT NULL DEFAULT 0 COMMENT 0未支付 1已支付 2已减免, create_time datetime DEFAULT NULL, pay_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT逾期费用表;3.3 借阅记录表状态机设计是核心中的核心借阅记录表是整个系统的核心它的状态字段决定了业务流程能不能闭环。我设计了四个状态1借阅中2已归还3已逾期仍然未还4已预约如果有预约功能CREATE TABLE borrow_record ( id bigint NOT NULL AUTO_INCREMENT, borrow_no varchar(32) NOT NULL COMMENT 借阅单号建议生成规则BORROW时间戳, book_id bigint NOT NULL, user_id bigint NOT NULL, borrow_date date NOT NULL COMMENT 借出日期, due_date date NOT NULL COMMENT 应还日期, return_date date DEFAULT NULL COMMENT 实际归还日期, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1借阅中 2已归还 3已逾期, renew_count int NOT NULL DEFAULT 0 COMMENT 续借次数, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_borrow_no (borrow_no), KEY idx_user_id (user_id), KEY idx_book_id (book_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT借阅记录表;借阅中、已归还、已逾期这三个状态的流转是核心。这里有个容易忽略的逻辑一个用户对同一本书理论上在“借阅中”状态下只能有一条记录。但如果你在代码里不校验用户连点两次借阅按钮就会生成两条借阅记录把库存直接搞成负数。解决方案有两条路一条是应用层校验另一条是数据库唯一索引。我当时是应用层校验 数据库唯一索引“双保险”这个等会儿在并发控制章节展开讲。3.4 为什么我坚持在业务层而不是数据库触发器里维护 borrowed_count有一些老代码会在借阅/归还时用触发器自动维护库存这优点是不容易漏但缺点非常明显业务逻辑被藏在了数据库里代码里根本看不到约束规则出问题的时候极难排查。而且触发器在分布式、多数据源的场景下很难维护。我把 borrowed_count 的维护放在 Service 层的同一个事务里先查图书判断 borrowed_count total_count然后新增借阅记录最后 update 图书表的 borrowed_count。这一个方法加Transactional保证了原子性。答辩时你可以说这是“应用层事务保证库存一致性”比触发器更容易让老师理解也方便你讲清楚代码逻辑。4. 用户与图书管理模块的实现细节虽然整套系统的核心是借阅流程但用户管理和图书管理这两个基础模块如果做不好后面所有功能都无从谈起。这两个模块对应的 Controller 层代码相对简单大部分逻辑由 MyBatis-Plus 的 BaseMapper 接口直接提供。4.1 用户注册时最容易犯的三个错密码直接用明文入库请务必用BCryptPasswordEncoder这是 Spring Security 里可以单独引用的工具类不需要引入整套 Security。用户名不做唯一校验哪怕是毕设用户表也必须有唯一索引。否则演示的时候注册两个同名账号后续业务基本没法查。前端只做非空校验后端不校验比如手机号格式、邮箱格式后端必须再校验一次。防止别人绕过前端直接调接口把脏数据写进库。注册流程的 Service 层代码大概长这样Override Transactional(rollbackFor Exception.class) public void register(RegisterDTO dto) { // 1. 校验用户名是否重复 Long count userMapper.selectCount( new LambdaQueryWrapperUser().eq(User::getUsername, dto.getUsername()) ); if (count 0) { throw new BizException(用户名已存在); } // 2. 密码加密后入库 User user new User(); user.setUsername(dto.getUsername()); user.setPassword(passwordEncoder.encode(dto.getPassword())); user.setRealName(dto.getRealName()); user.setPhone(dto.getPhone()); user.setRole(reader); user.setStatus(1); userMapper.insert(user); }这里注意Transactional(rollbackFor Exception.class)这个rollbackFor太关键了。Spring 默认只在遇到 RuntimeException 时回滚如果你抛的是自定义的BizException且它继承自 Exception那就不会触发回滚数据就写进去了。这个坑我在初学的时候踩得死死的。4.2 图书分页与条件检索MyBatis-Plus 分页插件配置图书列表页要支持按书名模糊搜、按分类筛选、按状态上架/下架筛选。这一步用 MyBatis-Plus 的分页插件配置很简单。先注册分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }Service 层查询public PageBookVO pageBooks(int pageNum, int pageSize, BookQueryDTO query) { PageBook page new Page(pageNum, pageSize); LambdaQueryWrapperBook wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(query.getBookName()), Book::getBookName, query.getBookName()) .eq(StringUtils.hasText(query.getCategory()), Book::getCategory, query.getCategory()) .eq(query.getStatus() ! null, Book::getStatus, query.getStatus()) .orderByDesc(Book::getCreateTime); PageBook result bookMapper.selectPage(page, wrapper); // 转为 VO并计算可借数量 PageBookVO voPage new Page(result.getCurrent(), result.getSize(), result.getTotal()); voPage.setRecords(result.getRecords().stream().map(book - { BookVO vo new BookVO(); BeanUtils.copyProperties(book, vo); vo.setAvailableCount(book.getTotalCount() - book.getBorrowedCount()); return vo; }).collect(Collectors.toList())); return voPage; }这段代码最好自己默写一遍因为“图书分页 条件查询”是 100% 会被问到的。特别要注意 LambdaQueryWrapper 的写法.eq(condition, column, value)第一个参数是布尔条件条件为 false 时该查询条件不拼接。这样就不用在 Controller 里手动写 if 判断了代码干净很多。4.3 图书上下架和库存预警的最简实现上下架就是一个 update status 字段的操作但要注意如果图书当前有借阅中记录不允许直接下架。所以 Service 层要做一次校验Long borrowingCount borrowRecordMapper.selectCount( new LambdaQueryWrapperBorrowRecord() .eq(BorrowRecord::getBookId, bookId) .in(BorrowRecord::getStatus, 1, 3) // 借阅中或逾期未还 ); if (borrowingCount 0) { throw new BizException(当前存在未归还的借阅记录无法下架); }库存预警更简单就是查询total_count - borrowed_count threshold的书阈值可以做成常量或配置public ListBook listLowStockBooks(int threshold) { return bookMapper.selectList( new LambdaQueryWrapperBook() .apply(total_count - borrowed_count {0}, threshold) ); }这个功能虽然实现简单但它属于“智能管理”的范畴答辩时完全可以当成一个亮点说系统会及时提示哪些书需要补库存。5. 借阅与归还主流程状态流转、事务与并发控制的完整实现现在进入重头戏。借阅、归还这两个操作是整套系统的业务核心也是答辩时最容易被深挖的地方。我先把完整流程和代码贴出来再逐段解释为什么要这么写。5.1 借阅操作的完整代码与逐行拆解借阅的业务规则在脑海中要先过一遍用户必须登录图书必须存在且已上架图书可借数量要大于 0用户当前不能已有未归还的同名书用户如果有未支付的逾期费用应提示先处理Override Transactional(rollbackFor Exception.class) public String borrowBook(Long userId, Long bookId) { // 1. 查询图书加行锁防止超借 Book book bookMapper.selectById(bookId); if (book null || book.getStatus() 0) { throw new BizException(图书不存在或已下架); } // 2. 校验库存 if (book.getBorrowedCount() book.getTotalCount()) { throw new BizException(库存不足); } // 3. 校验用户是否已经借了这本书且未归还 Long exists borrowRecordMapper.selectCount( new LambdaQueryWrapperBorrowRecord() .eq(BorrowRecord::getUserId, userId) .eq(BorrowRecord::getBookId, bookId) .in(BorrowRecord::getStatus, 1, 3) ); if (exists 0) { throw new BizException(你已借阅这本书请先归还); } // 4. 生成借阅记录 BorrowRecord record new BorrowRecord(); record.setBorrowNo(generateBorrowNo()); record.setBookId(bookId); record.setUserId(userId); record.setBorrowDate(LocalDate.now()); record.setDueDate(LocalDate.now().plusDays(30)); record.setStatus(1); borrowRecordMapper.insert(record); // 5. 更新图书在借数量 book.setBorrowedCount(book.getBorrowedCount() 1); bookMapper.updateById(book); return record.getBorrowNo(); }我故意在第 2 步先做了一次校验又在第 4、5 步更新了数量这就是一个典型的“读改写”过程。单用户操作没问题但两个用户同时借同一本书的最后库存时就可能出现“超借”。要彻底解决这个问题需要引入锁机制。作为毕设我觉得讲到这一步已经足够但如果想更严谨可以在 SQL 层用一个原子操作int updated bookMapper.updateBorrowedCount(bookId); // 对应 SQL: UPDATE book SET borrowed_count borrowed_count 1 // WHERE id #{bookId} AND borrowed_count total_count if (updated 0) { throw new BizException(库存不足请刷新后重试); }这种原子更新方案比先查再改靠谱得多也更容易在答辩时讲清楚“乐观锁/原子操作”的思想。我把两段代码对比着理解建议直接用原子更新方案。5.2 归还操作如何计算逾期费用归还相对借阅要复杂一些因为要判断是否逾期并且要联动逾期费用表。逻辑展开如下根据借阅记录 id 查询记录判断状态是否为“借阅中”或“逾期未还”设置实际归还日期更新状态为“已归还”如果实际归还日期晚于应还日期计算罚款金额并生成逾期费用记录更新图书表的在借数量减一Override Transactional(rollbackFor Exception.class) public void returnBook(Long borrowRecordId) { BorrowRecord record borrowRecordMapper.selectById(borrowRecordId); if (record null) { throw new BizException(借阅记录不存在); } if (record.getStatus() 2) { throw new BizException(该记录已归还请勿重复操作); } LocalDate today LocalDate.now(); // 1. 更新借阅记录状态 record.setReturnDate(today); record.setStatus(2); borrowRecordMapper.updateById(record); // 2. 判断是否逾期并生成费用记录 if (today.isAfter(record.getDueDate())) { long overdueDays ChronoUnit.DAYS.between(record.getDueDate(), today); BigDecimal fine BigDecimal.valueOf(overdueDays) .multiply(BigDecimal.valueOf(0.5)); // 每天0.5元 OverdueFine fineRecord new OverdueFine(); fineRecord.setBorrowId(record.getId()); fineRecord.setUserId(record.getUserId()); fineRecord.setFineAmount(fine); fineRecord.setStatus(0); overdueFineMapper.insert(fineRecord); } // 3. 更新图书在借数量 bookMapper.updateBorrowedCountDecrease(record.getBookId()); }这里有一个特别多同学忽略的地方归还后把逾期费用记录插入到另外一张表千万不要只在借阅记录上加一个“已逾期”字段。理由是如果一本书逾期 5 天按照规则应该收 2.5 元但管理员可能因为特殊情况给你减免你要是只在一个字段上打标记就丢失了“原本应收多少、实收多少、谁操作的”这些信息。单独建表相当于是留了审计日志这在业务上是非常合理的。5.3 单号生成规则、日期类型与计算细节借阅单号我用的规则是BORROW yyyyMMddHHmmss 4位随机数。单号生成不放进业务方法里而是抽了一个工具方法private String generateBorrowNo() { return BORROW new SimpleDateFormat(yyyyMMddHHmmss).format(new Date()) String.format(%04d, ThreadLocalRandom.current().nextInt(10000)); }注意这里用到了ThreadLocalRandom在高并发场景下比Math.random()的竞争小也可以顺嘴讲一嘴。日期计算上面用了ChronoUnit.DAYS.between(dueDate, today)这个 API 是 Java 8 时间库的目的就是避免你自己手动把 Date 转成 long 再算毫秒差那样代码可读性太差还容易出时区问题。整个项目我建议统一使用LocalDate用于日期和LocalDateTime用于时间戳数据库字段分别对应date和datetime配合 MyBatis-Plus 的自动类型处理基本不会出问题。6. 逾期管理、排行榜与统计报表把“智能管理”做成答辩亮点如果说前面那些是“基础功能”那这一章就是你从“会写 CRUD”升级到“懂业务系统”的关键。图书借阅系统的题目里带了“智能”两个字你怎么体现智能不是搞什么人工智能算法而是体现在“系统能自动判断、自动计算、自动提醒”这些规则化的能力上。6.1 定时任务每天自动把逾期记录状态从“借阅中”改为“逾期”对你没有看错状态机里“借阅中”和“逾期”不能只靠用户归还的那一刻去判断。比如一本书已经超过应还日期 10 天还没还你在列表页应该直接看到“状态已逾期”而不是等用户点归还时才弹出“你逾期了”。这个自动更新的逻辑用 Spring 自带的Scheduled就能实现Component public class OverdueJob { Resource private BorrowRecordMapper borrowRecordMapper; Scheduled(cron 0 0 2 * * ?) // 每天凌晨2点执行 public void processOverdueRecords() { ListBorrowRecord overdueList borrowRecordMapper.selectList( new LambdaQueryWrapperBorrowRecord() .eq(BorrowRecord::getStatus, 1) .lt(BorrowRecord::getDueDate, LocalDate.now()) ); if (overdueList.isEmpty()) { return; } overdueList.forEach(record - { record.setStatus(3); borrowRecordMapper.updateById(record); }); } }但定时任务不是万能的如果今天凌晨 2 点之后才逾期用户白天打开页面状态还是“借阅中”所以查询列表时不能只依赖状态字段还要在查询条件里动态判断一次// 查询时动态判断如果有记录超过应还日期且状态为借阅中实时标记为逾期 wrapper.and(w - w .eq(BorrowRecord::getStatus, 1) .ge(BorrowRecord::getDueDate, LocalDate.now()) .or() .eq(BorrowRecord::getStatus, 3) );这个动静结合的方案既保证了数据最终一致又不会因为定时任务没跑就展示错误状态。答辩的时候把这一套逻辑讲出来老师会知道你确实理解了“状态驱动业务”的道理。6.2 图书借阅排行榜与用户逾期排行统计类需求建议单独建一个统计 Service不要在原有的图书 Service 里写一堆聚合 SQL。我用 MyBatis-Plus 的Select注解写原生 SQL展示的是一张榜单图或者表格。图书借阅排行榜的核心 SQLSELECT b.id, b.book_name, b.author, COUNT(br.id) AS borrow_times FROM borrow_record br LEFT JOIN book b ON br.book_id b.id WHERE br.status IN (2, 3) GROUP BY b.id, b.book_name, b.author ORDER BY borrow_times DESC LIMIT 10用户逾期排行类似统计的是逾期次数SELECT u.id, u.username, u.real_name, COUNT(*) AS overdue_times FROM borrow_record br LEFT JOIN user u ON br.user_id u.id WHERE br.status 3 GROUP BY u.id, u.username, u.real_name ORDER BY overdue_times DESC LIMIT 10这两条 SQL 我在答辩前是能闭着眼睛写出来的所以建议你也一定要亲手敲一遍。它们不复杂但能体现你对多表关联和分组聚合的掌握。6.3 数据可视化不一定要用 ECharts但一定要有趋势图很多人一听说“统计报表”就想着引入 ECharts搞数据可视化大屏这没错。但如果你时间不够另一个更朴素的方案也能出效果直接用 Thymeleaf 模板渲染表格再加一个统计数字卡片展示“今日借出量、累计会员数、图书总量、逾期未还数”。我用 ECharts 画了“近 7 日借阅趋势”和“图书分类占比”两个图代码核心是 Controller 里返回 JSON 数据前端用 AJAX 拉取后 setOption。这里简单放一下后端返回折线图数据的方式GetMapping(/api/trend) ResponseBody public ResultListInteger last7DaysBorrowCount() { ListInteger counts new ArrayList(); LocalDate today LocalDate.now(); for (int i 6; i 0; i--) { LocalDate day today.minusDays(i); Long count borrowRecordMapper.selectCount( new LambdaQueryWrapperBorrowRecord() .eq(BorrowRecord::getBorrowDate, day) ); counts.add(count.intValue()); } return Result.success(counts); }这种图不需要你懂多高深的前端技术会 AJAX ECharts 模板就能搞定。关键是后端数据接口要设计得清晰前端拿到数组直接渲染。7. 我踩过的四个比较典型的坑提前帮你避开下面这些坑是我实际开发过程中花了大把时间排查的也是我在答辩时讲得比较细的部分。每个坑都对应一个比较经典的问题写出来给后来人参考。7.1 事务失效BizException不在RuntimeException体系内这个我在前面提过一嘴但值得单独展开。我当初定义了一个自定义异常public class BizException extends Exception { ... }结果在借阅方法里抛出BizException后用户扣减失败但借阅记录却插入成功了数据直接错乱。原因就是 Spring 声明式事务默认只对RuntimeException和Error回滚Exception 不会触发回滚。解决方案有两个让BizException继承RuntimeException更推荐全局异常处理也方便在Transactional上明确写rollbackFor Exception.class我两个方案都做了自定义异常继承 RuntimeException全局异常处理器统一捕获同时在每个写操作上加rollbackFor Exception.class双保险。7.2borrowed_count和total_count比较时的数据一致性如果按照“先查再改”的方式Book book bookMapper.selectById(bookId); if (book.getBorrowedCount() book.getTotalCount()) { throw new BizException(库存不足); } book.setBorrowedCount(book.getBorrowedCount() 1); bookMapper.updateById(book);这段代码在单线程下没问题但一旦两个请求同时执行两个线程都读到了borrowed_count 9, total_count 10都判断为“可以借”然后都执行加一结果库存成 11超借了。修复方式我已经在前面给出用一条原子的 UPDATE 语句让数据库来判断UPDATE book SET borrowed_count borrowed_count 1 WHERE id #{bookId} AND borrowed_count total_count受影响的函数返回值如果为 0说明条件不满足直接抛异常。这个方案不用显式加锁性能好原理也清晰答辩时可以好好讲。7.3 MyBatis-Plus 字段映射到 MySQL 保留字我给图书表加了desc字段作为“图书描述”结果一查询直接 SQL 语法错误排查半天才发现 MyBatis-Plus 自动生成的 SQL 里desc没有加反引号MySQL 把desc当成保留字处理了。解决方案很简单实体类上用TableField(desc)转义或者在设计表时直接避开保留字。我后来改成了description从此清净。这个坑看起来低级但遇到一次才知道多疼。7.4 前端页面提交的日期格式与后端 LocalDate 绑定失败在归还图书时如果是管理员帮用户操作要选一个“实际归还日期”我在后来版本中增加过这个功能。前端input typedate提交的格式是yyyy-MM-dd后端用DateTimeFormat(pattern yyyy-MM-dd)可以正常绑定。但如果你没用这个注解Spring 默认只能处理yyyy/MM/dd大概率直接报 400。这个坑让我意识到一个原则全局的日期格式化配置越早做越好。在 application.yml 里加上spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8但注意 jackson 配置只对 JSON 序列化生效对于表单提交还是要在 Controller 参数上用注解或者配置一个全局的WebDataBinder。8. 演示脚本与答辩要点别让代码白写代码写完了最后一步是准备演示和答辩。我见过不少代码写得挺好的人一到演示就卡壳手一抖把库存减成负数或者不知从何讲起。这里分享一套我实际用过的演示顺序和答题思路。8.1 建议的演示顺序先全局再主链路再亮点演示第一步登录管理员账号展示系统首页的统计卡片图书总量、用户数、在借数、逾期数这里光是数字就能让老师知道你做了统计功能。演示第二步图书管理 - 新增一本测试书设置库存 2 本然后展示列表页检索、分页、上下架操作。演示第三步借阅主链路——注册一个新用户借一本书然后模拟库存从 2 变成 1再展示借阅记录状态。演示第四步归还流程——先把某条借阅记录的应还日期改成昨天演示时可以直接改库或者做一个管理员调整日期的小功能再点击归还展示自动计算逾期费用并生成记录。演示第五步打开排行榜和近 7 日借阅趋势图。演示第六步展示数据库表结构讲清楚为什么这么设计。8.2 高频答辩问题整理去答辩前建议把下面这些问题提前背熟问题参考回答要点为什么选 Spring Boot 而不是 SSM简化配置、自动化装配、内置 Tomcat 便于部署、生态成熟同时没有丢失 Spring 的核心能力库存并发怎么解决原子化 UPDATE 语句 数据库行锁或者乐观锁 version 字段此处展示 SQL逾期费用怎么算的用 LocalDate 计算天数差每天 0.5 元生成独立费用记录如果你的表数据量大了怎么办索引设计user_id、book_id、status、分页插件查询、考虑读写分离或 Redis 缓存热点数据系统有什么可扩展点增加预约功能、消息提醒、邮件通知、对接支付 API 处理缴费8.3 努力把两个最容易被追问的技术细节准备准确第一个是Transactional的传播行为。老师可能会问“A 方法调 B 方法B 方法事务失效怎么回事”这是经典的自调用问题。如果你 Service 内部的this.returnBook()调用了另一个事务方法事务不会生效因为代理对象的方法调用没有经过代理。如果被问到了可以这样回答“在同一个类中通过 this 调用目标方法绕过了 Spring 生成的代理对象所以要注入自身引用或者拆分到不同的 Bean 里。”第二个是 MyBatis-Plus 和 MyBatis 的区别。你要能说清MyBatis-Plus 是在 MyBatis 基础上做的增强提供通用 Mapper、条件构造器 LambdaQueryWrapper、分页插件但不改变 MyBatis 的 ORM 核心机制。这两句话就能让老师知道你真的用过而不只是装了个依赖。9. 项目优化方向与后续可演进的功能其实写到这里整套系统已经能拿来答辩了。但如果你时间充裕或者想让这个项目在校招简历上稍微能打一点我建议再补下面几个方向。它们看起来像“附加题”但是每做一个小点都相当于多一个可以讲的故事。9.1 增加 Redis 缓存热点图书信息图书检索是读多写少的场景可以引入 Redis 把热门图书列表缓存起来设置 10 分钟过期。这个功能技术上不难但想讲清楚缓存穿透、缓存击穿、缓存雪崩这“三座大山”就需要好好准备一下。9.2 借阅预约功能让“在借数量”更合理加入预约功能后“可借数量”要改成“可用数量”即可用数量 总量 - 在借数量 - 预约数量这会引入新的状态已预约、待取书也要在借阅表中加一个预约状态。这个功能不算太难但能很好体现你设计状态机的能力。9.3 邮件/短信通知逾期提醒用 Spring Boot 自带的JavaMailSender可以很容易实现邮件发送每天定时把逾期未还的借阅记录发给用户。这个功能实用性强演示效果也不错。这三个方向建议不要全做挑一个做透就行。毕设的深度永远比广度重要把一件事的来龙去脉讲清楚比“功能很多但每个都很水”更值钱。我在实际做这套系统时最大的体会是图书租借系统看起来是 Java 面试里常见的 CRUD 项目但如果你愿意把业务状态机理清楚、把并发控制做严谨、把异常处理和事务边界划明白它完全可以从“课设”变成“能写在简历上的项目”。不要嫌这些功能太简单任何一个行业级项目都是从一张表、一个方法、一个事务开始长出来的。