
简介这是一份基于Java Web技术的图书管理系统毕业设计论文资源适合计算机相关专业学生在课程设计、毕业设计或自学项目开发时用作参考。内容完整覆盖系统设计全流程包含可行性分析、需求建模、数据库表结构设计、六大功能模块划分以及基于MVC模式与Struts框架的具体实现方案并对JSP页面开发、JDBC连接SQL Server等关键技术作了说明。资源为docx格式共1个文件压缩包大小为2.34MB便于直接查阅、修改和复用。文档结构清晰按引言、需求分析、总体设计、详细设计到系统实现逐章展开既有理论阐述也有模块实现逻辑可帮助读者理解图书管理系统的完整构建思路。已有385人学习使用对于需要完成同类课题或快速梳理系统设计框架的读者具有较高参考价值。1. Java Web 图书管理系统从课程设计到生产落地的完整路径“Java Web 图书管理系统”这个标题在课程设计和毕业设计里出现频率极高属于典型的“看起来简单、做起来全是细节”的项目。它解决的需求是任何小型图书馆都需要的图书档案管理、读者管理、借书还书与逾期记录外加一个基于角色的登录入口。适合的人群很明确刚学完 Java 基础想走一遍 Web 全流程的初学者以及需要快速交付课程设计的学生。这篇文章按我实际做这类系统的经验来写从技术选型、数据库建模到核心接口与踩坑全程都可以照着复现。2. 技术选型Servlet JSP 还是 Spring Boot Thymeleaf先给结论如果环境允许优先 Spring Boot 2.7.x MyBatis Thymeleaf如果学校强制 Servlet JSP也不要慌Servlet 3.0 JDBC 一样能把系统做完只是代码量和手工处理连接的细节会多不少。这个选择不只是在挑框架而是在挑你后面写代码时要处理的复杂度。2.1 两条路线的适用边界Servlet JSP 是老牌 Java Web 方案JSP 负责渲染页面Servlet 负责接收请求JDBC 直接操作数据库。优点是贴近底层Java Web 的请求-响应模型看得清清楚楚缺点是 JSP 里一旦塞了业务逻辑页面脚本和 Java 代码搅在一起后面加一个“图书续借”功能就要改好几个文件而且数据库连接靠手工获取和释放漏一个 close() 就可能把连接池拖垮。Spring Boot 则把容器内嵌、自动装配、依赖管理都处理掉了引入 spring-boot-starter-web、mybatis-spring-boot-starter、thymeleaf 三个 starter一个能跑的应用骨架就齐了。Thymeleaf 是服务端渲染模板写法和 HTML 很像对不熟悉前端框架的人最友好如果你更习惯前后端分离也可以把 Thymeleaf 换成 Vue REST 接口但图书管理系统这种内部工具型项目服务端渲染的维护成本明显更低。这里我想提醒一点不要拿 PHP 图书管理系统那种“一个脚本文件搞定查询和渲染”的思路来套 Java 版。Java Web 的惯例是严格分层控制层只做参数接收和视图转发业务层管事务和规则数据层管 SQL。分层第一眼看确实啰嗦但当你遇到“借书要同时扣库存和写入借阅记录任何一个失败都要回滚”这种需求时才会发现分层和事务才是这个项目的真正考点。2.2 最小依赖pom.xml 与配置文件我用的是 Spring Boot 2.7.18没有选 3.x原因是 3.x 强制 JDK 17而很多课程设计和公司旧环境的 JDK 还是 8。选 2.7.x 意味着 JDK 8 和 11 都能跑部署时省去一堆环境问题。下面是最小可用的 pom.xml 关键依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency /dependencies这里说明几个选择mybatis-spring-boot-starter 用的是 2.3.2这个版本对 Spring Boot 2.7 兼容性最好mysql-connector-j 在 Spring Boot 2.7 的依赖管理里默认是 8.0.33不需要写版本号。如果你在别的教程里看到mysql-connector-java那是旧坐标8.0.30 以后官方把 artifactId 改成了mysql-connector-j两个都能用但新项目建议直接用新的。starter-validation 是给后端参数校验用的图书管理里“书名不能为空”“借书人必须存在”这类规则用它写注解比在 Service 里写一堆 if 干净得多。spring-security-crypto 只用来做密码的 BCrypt 哈希比对不引入完整的 Spring Security 安全框架避免登录链路被安全过滤器接管。然后是 application.yml数据源和 MyBatis 的配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password hikari: maximum-pool-size: 20 minimum-idle: 5 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.library.entity configuration: map-underscore-to-camel-case: true thymeleaf: cache: falseurl 里useUnicodetruecharacterEncodingutf8是必须的少了这一串中文写入数据库后基本全变成问号serverTimezoneAsia/Shanghai解决 MySQL 8.x 时区导致的日期偏移 8 小时问题。map-underscore-to-camel-case设置为 true数据库的book_name才能自动映射到实体类的bookName这个配置省掉了手写 ResultMap 的大部分工作。thymeleaf 的 cache 在开发期必须关掉否则改一个 HTML 要重启应用才能看到效果。2.3 分层骨架与实体设计目录结构我一般这样组织按包来分src/main/java/com/example/library/ ├── controller/ # 接收请求返回视图或 JSON ├── service/ # 业务逻辑事务注解放在这层 ├── mapper/ # MyBatis 的 Mapper 接口 ├── entity/ # 与数据表对应的实体类 ├── interceptor/ # 登录拦截器 └── LibraryApplication.java src/main/resources/ ├── mapper/ # MyBatis XML 文件 ├── static/ # CSS/JS └── templates/ # Thymeleaf 页面controller/service/mapper 三层的依赖方向是单向的controller 调 serviceservice 调 mapper严禁 controller 直接调 mapper。理由很实在图书管理的规则以后一定会变比如“借书前检查读者是否有逾期未还”这种规则放 controller 会散落得到处都是放 service 则只需要改一处。实体类这里不过多展开Book 表对应 Book.java字段类型与数据库字段一一对应注意日期字段用 java.time.LocalDate 而不是 java.util.Date。补充一个经常被问到的选择为什么 Mapper 层选 MyBatis 而不是 MyBatis-Plus 或 Spring Data JPA。MyBatis-Plus 的 BaseMapper 确实能省掉大量 CRUD XML但它的乐观锁插件、逻辑删除等能力在图书管理这个场景里用不上而且很多课程设计的评分标准里“手写 SQL”是加分项MyBatis 原生 XML 写法在演示时更好讲清楚每个查询的逻辑。Spring Data JPA 则把抽象做得更彻底但一旦涉及多表联查和动态条件JPQL 的复杂度和排查成本比 MyBatis XML 高学习曲线对刚从 JSP 过来的人不友好。做图书管理系统MyBatis 是最不容易出幺蛾子的中间态。3. 数据库设计图书、读者、借阅三张表怎么建模图书管理系统的数据模型不复杂但三张表之间的关联和状态流转却是最容易出分歧的地方。3.1 三张核心表的结构设计第一张是图书表 book我通常在“书”还是“册”之间先做个选择。如果是课程设计级别的系统一本书对应一条记录就够了如果真的要管库存就要拆成 book书目和 book_item馆藏副本两张表。这里按最常见的一书一条记录来设计字段类型说明idBIGINT PK AUTO_INCREMENT主键book_nameVARCHAR(100) NOT NULL书名authorVARCHAR(50)作者publisherVARCHAR(80)出版社isbnVARCHAR(20)ISBN 编号建议加唯一索引totalINT NOT NULL DEFAULT 1馆藏总量availableINT NOT NULL DEFAULT 1可借数量categoryVARCHAR(30)分类create_timeDATETIME入库时间把available直接冗余到 book 表里是很多教程没讲但实际最常用的做法。借书时available - 1还书时available 1查询“这本书能不能借”只需要看这一个字段。缺点是 available 可能与借阅明细不一致所以后面要在借还逻辑里用事务保证一致性。索引设计上book 表除了主键就是 isbn 的唯一索引borrow_record 表的 book_id 和 reader_id 都建了普通索引因为最频繁的查询是“某读者借了哪些书”和“某本书被谁借着”两个字段单独索引就够不需要联合索引。联合索引只有在同时按两个字段过滤时才划算比如“查询某读者借的某本书”但这种查询在界面上很少单独出现。第二张是读者表 reader字段类型说明idBIGINT PK AUTO_INCREMENT主键reader_nameVARCHAR(50) NOT NULL姓名phoneVARCHAR(20)手机号passwordVARCHAR(60) NOT NULL登录密码BCrypt 哈希statusTINYINT DEFAULT 11 正常0 停用create_timeDATETIME注册时间密码字段建议存 BCrypt 哈希而不是明文用 spring-security-crypto 的 BCryptPasswordEncoder 生成。status 字段也很重要读者逾期不还时可以直接停用账号。3.2 借阅表的状态设计与冗余字段借阅表的字段比较多因为要支撑“谁借了什么、什么时候借的、该什么时候还、现在什么状态”这几个查询字段类型说明idBIGINT PK AUTO_INCREMENT主键book_idBIGINT NOT NULL图书 idreader_idBIGINT NOT NULL读者 idborrow_timeDATE NOT NULL借出日期due_timeDATE NOT NULL应还日期一般为借出 30 天return_timeDATE NULL实际归还日期statusTINYINT DEFAULT 11 借出中2 已归还3 逾期due_time这个字段是我特别建议冗余的。虽然它可以用borrow_time 30 天算出来但冗余以后查询逾期记录只需要一条 SQLSELECT * FROM borrow_record WHERE status 1 AND due_time CURDATE()不用在 Java 代码里逐条计算再比对性能和数据清晰度都好很多。status 用 TINYINT 维护状态机借出时置 1归还时置 2逾期通过定时任务扫描把状态改成 3。这里有个设计取舍逾期状态是否要单独落库。我的做法是扫描任务只把 overdue 的借阅记录 status 从 1 改成 3同时把 reader.status 置为 0 停用如果你不想引入定时任务查询时用due_time CURDATE()判断也算逾期但“停用读者”这个动作就没办法自动触发。3.3 建表 SQL 与初始化数据下面是完整的建表脚本直接在建库后执行CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE library_db; CREATE TABLE book ( id BIGINT AUTO_INCREMENT PRIMARY KEY, book_name VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(80), isbn VARCHAR(20) UNIQUE, total INT NOT NULL DEFAULT 1, available INT NOT NULL DEFAULT 1, category VARCHAR(30), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE reader ( id BIGINT AUTO_INCREMENT PRIMARY KEY, reader_name VARCHAR(50) NOT NULL, phone VARCHAR(20), password VARCHAR(60) NOT NULL, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE borrow_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, book_id BIGINT NOT NULL, reader_id BIGINT NOT NULL, borrow_time DATE NOT NULL, due_time DATE NOT NULL, return_time DATE, status TINYINT DEFAULT 1, INDEX idx_book (book_id), INDEX idx_reader (reader_id) ) ENGINEInnoDB;建表时一定要显式指定ENGINEInnoDBMyISAM 不支持事务而借书还书这种“两步操作”必须有事务兜底——见过不止一次教程用默认引擎建表结果事务注解形同虚设。字符集用 utf8mb4 而不是 utf8因为 utf8 在 MySQL 里最多 3 字节遇到 emoji 或生僻字书名会直接插入失败。初始化几条测试数据方便后面联调INSERT INTO book (book_name, author, publisher, isbn, total, available, category) VALUES (深入理解Java虚拟机, 周志明, 机械工业出版社, 9787111641247, 5, 5, 技术), (Java编程思想, Bruce Eckel, 机械工业出版社, 9787111213826, 3, 3, 技术), (MySQL必知必会, Ben Forta, 人民邮电出版社, 9787115320154, 2, 2, 数据库); -- 密码列请替换为 BCryptPasswordEncoder.encode(123456) 的哈希结果 INSERT INTO reader (reader_name, phone, password, status) VALUES (张三, 13800000001, 此处填BCrypt哈希, 1), (李四, 13800000002, 此处填BCrypt哈希, 1);BCrypt 哈希不要直接复制明文密码你需要用 BCryptPasswordEncoder 生成之后再贴进来。如果想快速测试也可以用{noop}123456前缀存明文密码配合 DelegatingPasswordEncoder 识别但仅限于本地调试交付前必须换成 BCrypt。4. 后端实现登录鉴权、图书 CRUD 与借还书事务这一章按最小闭环来做登录、查询图书、借书、还书、展示列表。每一块都给出关键代码和边界说明。4.1 登录鉴权Session 与拦截器图书管理系统的权限不复杂一个管理员角色就够所以用 Session 拦截器比引入 Spring Security 全家桶划算。登录成功后把用户信息放进 Session拦截器检查 Session 是否有效Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user request.getSession().getAttribute(loginUser); if (user null) { // 未登录跳回登录页。如果是 AJAX 请求返回 401 状态码更合适 response.sendRedirect(request.getContextPath() /login); return false; } return true; } }Configuration public class WebConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; public WebConfig(LoginInterceptor loginInterceptor) { this.loginInterceptor loginInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /doLogin, /css/**, /js/**); } }preHandle 里返回 false 表示请求被拦截不会进入 Controller。注意excludePathPatterns要放行登录页和静态资源否则 CSS/JS 被拦后页面样式全丢现象是“登录成功了但页面像纯文本”排查方向经常在这里。Session 默认超时时间是 30 分钟想要改短可以在 application.yml 里加server.servlet.session.timeout: 60m。提示如果页面里混用了 AJAX 请求拦截器里对这类请求不要用 redirect返回 401 状态码前端再统一跳转登录页否则浏览器控制台会报“无法获取响应头”之类的奇怪错误。登录接口的逻辑很简单按用户名查出读者记录比对密码。密码校验用 BCrypt 比对不要在 SQL 里直接做WHERE password #{password}的明文比对。PostMapping(/doLogin) public String doLogin(String readerName, String password, HttpSession session) { Reader reader readerMapper.findByReaderName(readerName); if (reader ! null BCrypt.checkpw(password, reader.getPassword())) { session.setAttribute(loginUser, reader); return redirect:/book/list; } return redirect:/login?error1; }这里存在一个不算 bug 但很常见的争议点密码比对放在 Controller 里还是 Service 里。我选择放 ServiceController 只处理参数和跳转上面的代码为了简洁把逻辑写在 Controller实际项目建议抽到AuthService.login()。原因不复杂——登录规则将来可能加验证码、加失败锁定放 Service 才不散。4.2 图书 CRUDMyBatis Mapper 与分页图书列表页是系统的主页面需要支持按书名模糊查询和分页。MyBatis 里模糊查询的写法是经典坑点先看 MapperMapper public interface BookMapper { ListBook selectPage(Param(bookName) String bookName, Param(offset) int offset, Param(limit) int limit); long count(Param(bookName) String bookName); Book selectById(Param(id) Long id); int insert(Book book); int update(Book book); int deleteById(Param(id) Long id); }select idselectPage resultTypecom.example.library.entity.Book SELECT * FROM book where if testbookName ! null and bookName ! AND book_name LIKE CONCAT(%, #{bookName}, %) /if /where ORDER BY id DESC LIMIT #{offset}, #{limit} /select模糊查询这里用CONCAT(%, #{bookName}, %)不要直接写LIKE %${bookName}%后者是把字符串拼进 SQLbookName 里含一个单引号就能让你从“查询”变成“注入”。#{bookName}是预编译参数占位符MyBatis 会把它转成?绑定安全性完全不一样。分页参数offset和limit由 Service 层计算Controller 只接收 pageNum 和 pageSize 两个整数参数这个转换逻辑放 Service 更合适。删除图书是个容易忽略外键的地方。borrow_record 表虽然在建表 SQL 里没写 FOREIGN KEY 约束但业务上存在关联。我处理的方式是先查该书的借阅记录里是否有 status1借出中有就禁止删除提示“这本书还有未归还记录”。这是业务外键比数据库外键更灵活也避免了一连串的级联删除问题。4.3 借书与还书Transactional 的事务边界借书是整个系统里最需要强调事务的场景。一次借书操作包含两步写操作插入一条 borrow_record同时把 book.available 减 1。两步之间任何一个失败另一个必须回滚否则会出现“借阅记录插上了但库存没减”或反过来。Service public class BorrowService { private final BookMapper bookMapper; private final BorrowRecordMapper borrowRecordMapper; Transactional(rollbackFor Exception.class) public void borrow(Long bookId, Long readerId) { Book book bookMapper.selectById(bookId); if (book null || book.getAvailable() 1) { throw new BusinessException(图书不存在或已借完); } // 检查该读者是否有未还的同一本书 int count borrowRecordMapper.countActiveByBookAndReader(bookId, readerId); if (count 0) { throw new BusinessException(你已借过这本书且未归还); } LocalDate today LocalDate.now(); BorrowRecord record new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowTime(today); record.setDueTime(today.plusDays(30)); record.setStatus(1); borrowRecordMapper.insert(record); bookMapper.decreaseAvailable(bookId); } }几个细节Transactional(rollbackFor Exception.class)必须显式声明 rollbackFor因为 Spring 默认只对 RuntimeException 回滚而自定义的BusinessException如果继承自 Exception受检异常默认不会触发回滚这是事务“看似没生效”的第一大原因。借书前查available和借阅记录都是读操作在事务里读的是当前快照两个并发请求同时进来时还是可能同时通过检查要彻底防住需要在 SQL 层做原子更新下面第 5 章展开说。还书的逻辑同样两步更新 borrow_record 的 return_time 和 status同时把 book.available 加 1。事务注解同样放在 Service 方法上Transactional(rollbackFor Exception.class) public void returnBook(Long borrowRecordId) { BorrowRecord record borrowRecordMapper.selectById(borrowRecordId); if (record null || record.getStatus() ! 1) { throw new BusinessException(借阅记录不存在或已归还); } record.setStatus(2); record.setReturnTime(LocalDate.now()); borrowRecordMapper.update(record); bookMapper.increaseAvailable(record.getBookId()); }这一步有一个经常被忽略的边界如果还书时间晚于 due_time要不要自动算逾期我建议还书时不改状态为 3而是保留 status2已归还逾期与否由 overdue_time 的扫描任务负责或者直接在页面查询时用return_time due_time判断。把“借阅状态”和“逾期标记”拆开后续做罚款明细才不会因为重复更新状态而出错。5. 避坑手册图书管理系统里最常见的 5 个坑这个系统看起来代码量不大但运行时容易出问题的点非常集中。按我自己的经历列出这 5 个按“现象 → 原因 → 解决”写。5.1 乱码中文变问号现象页面能打开但书名、读者姓名全是问号写入数据库的字段用命令行查也是问号。原因三层乱码可能叠加——数据库连接串没加characterEncodingutf8MySQL 表不是 utf8mb4HTTP 响应没设置 UTF-8。最常见的是第一层因为 Spring Boot 的server.servlet.encoding默认已经处理了请求和响应编码但 JDBC 连接串里的编码参数一旦缺失数据在入库那一步就已经坏了。解决按 3.3 的建表脚本重建数据库字符集必须 utf8mb4确认 application.yml 的 JDBC url 里带useUnicodetruecharacterEncodingutf8。如果已经写入坏数据只能 DELETE 后重新插字符集改不会自动修复已损坏的数据。排查时先在数据库命令行里执行SHOW CREATE TABLE book看字符集再确认连接串按这个顺序定位。5.2 日期前端显示一串数字时间戳现象Thymeleaf 页面上显示借书日期渲染成1700000000000这样一串长数字。原因实体类里的 LocalDate 字段在 JSON 或模板渲染时被转换成了 epoch 毫秒数。Spring Boot 的默认 Jackson 配置对 LocalDate 序列化输出是时间戳格式而 Thymeleaf 直接读对象属性时也可能拿到转换后的值。解决在 application.yml 里统一配置日期格式spring: jackson: date-format: yyyy-MM-dd time-zone: Asia/Shanghai同时 Thymeleaf 模板里用#temporals.format(book.createTime, yyyy-MM-dd)显式格式化。这个坑的迷惑性在于不同字段表现不一致——LocalDate和LocalDateTime的处理方式不一样排查时先确认字段类型再找序列化配置。5.3 并发同一本书被两个人同时借走现象库存显示 available1两个读者同时提交借书请求结果都成功了book.available 变成 -1。原因两个请求同时通过了available 1的检查然后各自执行INSERT和UPDATE。Java 代码里的“先查后改”不是原子操作事务隔离级别默认是 READ COMMITTED读到的快照不会阻止并发写入。解决把库存扣减改成一条原子 SQLUPDATE book SET available available - 1 WHERE id #{id} AND available 0然后在 Service 里检查这条 UPDATE 的返回行数为 0 说明没有可借库存直接抛异常。这是典型的“乐观锁思路”用 WHERE 条件保证并发下只有一个请求能更新成功。课程设计在线演示时并发量不大但答辩时评委一定会问这个问题提前处理比现场翻车好。5.4 注入${} 与 #{} 混淆现象搜索框里输入1 OR 11返回了全部图书列表或者直接报 SQL 语法错误。原因MyBatis 的${}是字符串直接替换#{}是预编译参数。模糊查询、排序字段、表名这类动态位置新手容易图省事写${}结果输入内容被拼进 SQL 执行。解决所有用户输入都用#{}。需要动态拼接的地方如排序字段用白名单映射比如前端传sortname后端Map.get(name)得到book_name再拼进 SQL。白名单之外的值直接拒绝不要试图用过滤单引号来防注入过滤永远是防不完的。5.5 事务Transactional 没生效现象借书时故意在borrowRecordMapper.insert(record)之后抛一个 RuntimeException结果事务回滚了但我把异常换成受检异常后数据照样写进去了。原因Spring 默认只对 RuntimeException 和 Error 回滚受检异常默认不触发回滚。很多教程直接写Transactional不带参数业务代码一抛Exception受检事务完全不回滚而且没有任何报错提示特别难察觉。这种失效不报错、数据悄悄写进去的表现是 Spring 事务里最玄学的一个问题。解决统一写成Transactional(rollbackFor Exception.class)这是最省心的配置。另外注意两点方法必须是 public且不能通过同类内部 this 调用来间接调用否则代理失效事务同样不生效——这个我在自己项目里踩过一次把this.borrow()改成通过注入的 Service 调用就正常了。如果上面五个都没覆盖到你的问题我一般的排查顺序是先看控制台有没有异常堆栈其次看 SQL 日志确认 MyBatis 实际执行了什么语句再看页面请求的网络面板确认参数有没有到后端。在 application.yml 里加logging.level.com.example.library.mapper: debug所有 Mapper 的 SQL 和参数都会打印出来这个日志在联调阶段基本不离手。6. 验证与进阶跑通之后怎么让这个项目更值钱系统能跑只是第一步要让它经得起验收或面试追问还需要一套验证清单和几个加分改进。6.1 最小验收清单我习惯按这个顺序过一遍管理员登录 → 新增一本图书 → 检索图书 → 借书核对库存减 1 → 还书核对库存加 1 → 重复借同一本书应被拦截 → 删除有借阅记录的图书应被拦截 → 直接访问 /book/list 未登录应跳转登录页。这八项覆盖了核心链路和权限边界全部通过说明系统基本可信。6.2 三个值得加的进阶功能第一个是借阅逾期扫描。用 Spring 的Scheduled(fixedDelay 86400000)每天跑一次任务把due_time CURDATE() AND status 1的记录置为逾期状态同时把对应 reader 的 status 置为 0 停用。这个功能代码不超过 30 行但让系统从“演示级别”变成了“有业务闭环”。第二个是操作日志。用 AOP 注解给修改类接口加日志记录记录谁在什么时间对哪本书做了什么操作。图书管理系统最容易被质疑的点就是“数据错了怎么办”有日志至少能把责任链说清楚。第三个是图书封面与 ISBN 查询。如果不想接外部 API就把封面图片上传到本地目录路径存进 book 表ISBN 在新增时做唯一约束校验避免同一本书重复录入。这三个功能里我建议优先做逾期扫描因为它在数据结构上驱动了“状态机”这个设计面试时可以从状态流转讲到定时任务再到事务回滚是性价比最高的一个加分区。说句实在话我做过好几轮这类管理系统最大的血泪教训是不要等到答辩前一天才连数据库联调。本地把 MySQL 起好、把 5.3 的并发问题改完比优化页面样式重要得多。希望你做完之后不仅拿到一个能交差的系统也能把“事务边界”和“分层”这两个词的体感留下来。希望帮到你。本文还有配套的精品资源点击获取