ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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

简介:一份基于 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 JPA?JPA 自动建表、自动映射确实方便,但图书管理里有组合查询、分页、条件更新,这些 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 你的密码 thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/**/*.xml type-aliases-package: com.example.library.entity

这段配置里有三个参数是血泪经验。characterEncoding=utf8 负责让中文在 JDBC 连接层不乱码;serverTimezone=Asia/Shanghai 解决 MySQL 8 与本地时区不一致导致的报错;allowPublicKeyRetrieval=true 是为了避免 MySQL 8 在首次连接时因公钥获取方式连接失败。开发阶段把 thymeleaf.cache 关掉,改完页面刷新就生效,不用重启。

然后按 controller / service / mapper / entity 四层建包。实体类字段和表字段一一对应,时间类型统一用 LocalDateTime,不要混搭 java.util.Date,否则序列化和比较都会别扭。BookMapper 接口定义一个带关键词的分页查询:

@Mapper public interface BookMapper { List<Book> page(@Param("keyword") String keyword, @Param("offset") int offset, @Param("limit") int limit); }

对应的 XML 写在 resources/mapper/BookMapper.xml 里:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.library.mapper.BookMapper"> <select id="page" resultType="Book"> SELECT id, title, author, isbn, category, status, create_time FROM book <where> <if test="keyword != null and keyword != ''"> AND title LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY id DESC LIMIT #{offset}, #{limit} </select> </mapper>

为什么这里用<where>标签而不是写WHERE 1=1:<where>会自动去掉第一个多余的 AND,keyword 为空时它整个不输出 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 List<Book> 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?keyword=Java&page=1&limit=10,能按书名分页查出数据,就说明数据源、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 连接串没加 characterEncoding=utf8、MySQL 表建库时默认成了 latin1、前端页面没声明 UTF-8 编码。只改一处往往无效。

解决:连接串统一加 characterEncoding=utf8,建表指定 CHARSET=utf8mb4,前端页面设置 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 到 status=0,都过了业务判断,然后都执行 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 id="search" resultType="Book"> SELECT * FROM book <where> <if test="title != null and title != ''"> AND title LIKE CONCAT('%', #{title}, '%') </if> <if test="category != null and category != ''"> AND category = #{category} </if> <if test="status != null"> AND status = #{status} </if> AND deleted = 0 </where> ORDER BY create_time DESC </select>

注意 AND deleted = 0 放在<where>内部,这样即便前三个条件都为空,where 标签也能正确生成只含该条件的查询。status 用<if test="status != 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 优化。把这些边界想清楚再动手,比多写两个页面更值钱。希望这个经典项目的拆解过程能帮到你,也欢迎你按自己的业务量把罚金阶梯、副本管理、读者自助续借一个个加进来,这套骨架是撑得住的。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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