
简介基于Java Web的图书管理系统的设计与实现.docx是一份面向高校计算机相关专业学生及Java Web初学者的毕业设计/课程设计参考文档针对学校图书管理中的读者信息、图书流通、借还登记等场景给出完整解决方案。资源包仅1个docx文档整体约2.34MB包含从需求分析、总体设计、数据库设计到系统实现的完整论述并详细介绍了系统设置、读者管理、图书管理、图书借还、系统查询与更改口令六大功能模块以及MVC设计模式、Struts框架和SQL Server数据库的整合应用。文档还列出了图书信息表、读者信息表、借阅信息表等多个关键数据表结构方便读者直接参考建表语句与功能流程。目前已有384人学习阅读适合正在撰写相关论文或需要快速搭建图书管理系统原型的开发者使用。1. 图书管理系统这个经典项目为什么还值得认真做一遍这几年我在带新人时发现一个现象很多人觉得“基于Java Web的图书管理系统”是毕业设计里最老掉牙的题目网上源码一抓一大把不值得自己动手。但真让他们讲清楚“用户登录后借书系统怎么保证库存不被超借管理员改一本书的价格事务边界在哪里”多数人是说不明白的。这个标题背后不是一套CRUD而是Java Web开发里最完整的一条主线请求怎么路由到Servlet或Controller业务层怎么处理事务持久层怎么避免SQL注入前端页面怎么跟后端数据绑定。把这套系统从头设计并实现一遍等于把Java Web的骨架重新搭了一遍。这篇笔记适合两类人一是准备做课程设计或毕业设计的学生想拿出一套能演示、能答辩的系统二是已经会写几个Servlet但没串过全流程的开发者想看看一个完整项目里事务、分页、日志这些细节是怎么落地的。接下来我按自己做过的一个方案从技术选型到避坑完整走一遍。2. 技术选型与分层设计先定JDK、Servlet容器和数据库再谈功能2.1 SSM还是ServletJSP先看你的目标和运行环境图书管理系统最常见的实现组合有三种纯Servlet JSP JDBC、SSMSpring Spring MVC MyBatis、Spring Boot MyBatis。如果是课程设计老师通常会指定“Java Web”这个范围很宽用纯Servlet也能交差但我不建议。理由很简单纯Servlet方案里事务、依赖注入、参数绑定全要手写代码量会膨胀到几千行而且很容易写出连接泄漏的隐患。SSM是过去十年Java Web课程设计里最主流的组合它把Spring的IOC容器、Spring MVC的请求映射、MyBatis的SQL管理分开每一部分都能在答辩时讲清楚“为什么这么选”。Spring Boot虽然更现代但很多学校的Java Web课程还没更新到Boot老师看到xml配置反而觉得更“符合课程预期”。我的建议是如果用Tomcat 8或9JDK 1.8就选SSM如果环境允许并且你想省去大量xml配置也可以选Spring Boot但本文后续的配置思路同样适用只是自动配置帮你去掉了手工步骤。我自己更倾向于SSM因为遇到问题时你能看到完整的处理链而不是被自动配置的黑匣子挡住。2.2 分层架构表现层、业务层、持久层各自管什么图书管理系统的分层一定要严格否则项目一大就会乱。常见的分层是三层表现层Controller接收HTTP请求校验参数格式调用业务层返回页面或JSON。这里不写SQL不写业务规则。业务层Service承载核心逻辑比如借书时检查库存、检查读者是否已借满、操作借书记录和更新库存。事务就加在这一层的方法上。持久层DAO/Mapper只负责SQL执行和数据映射不处理业务判断。我见过很多翻车项目把业务逻辑写在Controller里比如直接在Servlet里写了UPDATE book SET number number - 1然后跳转成功页。这会导致两个问题一是复用性差以后如果增加一个微信小程序端还得复制一遍逻辑二是事务很难控制因为Servlet是单例多线程的手动管理连接非常容易漏关。所以从最开始就要坚持Controller只做转发和参数接收Service处理一切规则Mapper只做数据访问。2.3 数据库表设计book、user、borrow记录的三张核心表图书管理系统最少需要三张表图书表、用户表、借阅记录表。如果要做分类统计再加一张分类表。我给出一个经过实践的表结构字段名和类型可以直接用CREATE TABLE book ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(128) NOT NULL COMMENT 书名, author varchar(64) DEFAULT NULL COMMENT 作者, publisher varchar(128) DEFAULT NULL COMMENT 出版社, isbn varchar(32) DEFAULT NULL COMMENT ISBN号, total int(11) NOT NULL DEFAULT 1 COMMENT 总藏书量, available int(11) NOT NULL DEFAULT 1 COMMENT 可借数量, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(64) NOT NULL, password varchar(128) NOT NULL COMMENT BCrypt加密后存储, role tinyint(4) NOT NULL DEFAULT 0 COMMENT 0普通用户 1管理员, max_borrow int(11) NOT NULL DEFAULT 5 COMMENT 最大借书数, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE borrow_record ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, book_id bigint(20) NOT NULL, borrow_time datetime NOT NULL, return_time datetime DEFAULT NULL, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0借出 1已还, PRIMARY KEY (id), KEY idx_user (user_id), KEY idx_book (book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个细节容易被忽略。一是available字段必须有不能靠total减去记录数来算因为还书时状态可能不一致单独维护可借数量才能用UPDATE book SET available available - 1 WHERE id ? AND available 0这样的原子操作防超借。二是密码字段要用BCrypt加密不能用MD5。MD5在今天的算力下已经可以被彩虹表轻易碰撞这个点也常常是答辩老师喜欢追问的地方。3. 搭建项目骨架并用Maven管理依赖从零跑通最小页面3.1 用Maven创建war包项目pom.xml关键配置SSM项目的标准打包方式是war包部署到Tomcat的webapps目录下。我用Maven创建项目后先调整pom.xml。下面是一个精简且能直接用的配置版本号都经过验证project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdbook-manager/artifactId version1.0.0/version packagingwar/packaging properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target spring.version5.2.22.RELEASE/spring.version /properties dependencies dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.6/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.3.0/version /dependency dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjstl/groupId artifactIdjstl/artifactId version1.2/version /dependency /dependencies /project注意这里有个容易踩的坑pagehelper-spring-boot-starter虽然带了“spring-boot”字样但它也能在普通SSM项目里使用只要你在MyBatis配置里引入PageInterceptor即可。另外javax.servlet-api的scope必须是provided因为Tomcat里已经有了Servlet容器类如果设成compile打出的war包会把servlet-api也带上运行时会报类冲突。3.2 配置web.xml与DispatcherServletSpring MVC的入口SSM项目的web.xml负责启动Spring容器和Spring MVC的DispatcherServlet。我用一个示例说明web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping filter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mapping /web-appforceEncoding设为true意味着不仅响应编码是UTF-8请求体里的参数也会被强制按UTF-8解码。这一个配置如果不加POST提交的中文大概率会乱码。而DispatcherServlet的url-pattern用/是为了让Spring MVC接管所有请求静态资源比如CSS、JS需要额外在spring-mvc.xml里配置放行。3.3 写一个最简单的图书列表页验证环境环境搭完后我先不写复杂业务而是做一个最小验证从数据库查出图书列表在JSP页面上渲染出来。这样才能确认Spring MVC、MyBatis、JSP整条链路是通的。先定义Book实体和Mapperpackage com.example.book.entity; public class Book { private Long id; private String name; private String author; private String publisher; private String isbn; private Integer total; private Integer available; // getter/setter 省略 }package com.example.book.mapper; import com.example.book.entity.Book; import org.apache.ibatis.annotations.Param; import java.util.List; public interface BookMapper { ListBook selectAll(); Book selectById(Param(id) Long id); }对应的BookMapper.xml放在resources/mapper目录下?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.book.mapper.BookMapper select idselectAll resultTypecom.example.book.entity.Book SELECT id, name, author, publisher, isbn, total, available FROM book ORDER BY id DESC /select select idselectById resultTypecom.example.book.entity.Book SELECT id, name, author, publisher, isbn, total, available FROM book WHERE id #{id} /select /mapperController很简单Controller RequestMapping(/book) public class BookController { Autowired private BookService bookService; GetMapping(/list) public String list(Model model) { model.addAttribute(books, bookService.listAll()); return book/list; } }JSP里用JSTL遍历% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html head title图书列表/title /head body table border1 tr th书名/thth作者/thth可借数/th /tr c:forEach items${books} varb tr td${b.name}/td td${b.author}/td td${b.available}/td /tr /c:forEach /table /body /html这里要说明的是MyBatis里#{id}是预编译参数会生成?占位符能防SQL注入而${id}是直接拼接字符串除非你确认值是白名单内容否则坚决不用。很多图书管理系统的注入漏洞就是有人把排序字段用${}拼进去导致的。4. 实现图书增删改查与借还流程核心代码与参数说明4.1 图书实体与MyBatis映射字段类型对应关系上面的实体类已经建好了但要注意Java类型与MySQL类型的映射细节。比如MySQL的tinyint映射到Java的Integer没问题但datetime映射到java.util.Date时如果字段为nullMyBatis能正常处理。有一个容易被忽视的坑是available字段它是业务上的可借数量但你如果用了UPDATE book SET available available - 1这样的一句SQLMyBatis里就只需要传入id不需要传入当前数量。这在并发环境下是安全的因为数据库的行锁会串行化这步操作。图书的增删改查中新增和修改的表单字段差不多但更新时要避免把total和available搞混。比如管理员修改一本书的总量如果原来的total是10available是3现在把total改成20available是应该变成多少合理的做法是差值增加available available (#{newTotal} - #{oldTotal})。业务上最好单独提供一个“增加库存”的操作而不是直接覆盖available。4.2 实现分页查询PageHelper的用法与pageNum/pageSize参数图书列表超过几十条后必须分页否则页面卡顿且不专业。我用的是PageHelper插件它从MyBatis插件机制里拦截SQL自动拼LIMIT。用法如下package com.example.book.service; import com.example.book.entity.Book; import com.example.book.mapper.BookMapper; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.List; Service public class BookService { Autowired private BookMapper bookMapper; public PageInfoBook page(int pageNum, int pageSize) { // 这一行必须在查询之前调用只对紧接着的下一条查询生效 PageHelper.startPage(pageNum, pageSize); ListBook books bookMapper.selectAll(); // PageInfo里包含总条数、总页数、当前页数据等 return new PageInfo(books); } }Controller里接收参数GetMapping(/list) public String list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, Model model) { PageInfoBook pageInfo bookService.page(pageNum, pageSize); model.addAttribute(pageInfo, pageInfo); return book/list; }这里有两个参数必须说清楚。pageNum从1开始如果你在页面上传0PageHelper会当作第1页处理但建议前端统一从1开始。pageSize不要给太大一般10到20即可如果允许用户自定义到100数据库压力会明显上升。另外PageHelper有很著名的线程安全问题后面避坑章节专门讲。JSP里加一个简单的分页导航用pageInfo.pageNum、pageInfo.pages这些属性。这一步能让你在答辩时展示“我考虑了数据量增长后的查询效率”。4.3 借书/还书的业务逻辑事务边界与状态校验借书是整个系统最核心的逻辑我先画一个业务规则普通用户最多借5本一本书当前可借数大于0才能借同一个用户不能同时借走同一本书两本。对应Service代码package com.example.book.service; import com.example.book.entity.Book; import com.example.book.entity.BorrowRecord; import com.example.book.entity.User; import com.example.book.mapper.BookMapper; import com.example.book.mapper.BorrowRecordMapper; import com.example.book.mapper.UserMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Date; Service public class BorrowService { Autowired private BookMapper bookMapper; Autowired private BorrowRecordMapper borrowRecordMapper; Autowired private UserMapper userMapper; Transactional(rollbackFor Exception.class) public void borrowBook(Long userId, Long bookId) { // 1. 校验用户 User user userMapper.selectById(userId); if (user null) { throw new RuntimeException(用户不存在); } int currentBorrowCount borrowRecordMapper.countBorrowingByUser(userId); if (currentBorrowCount user.getMaxBorrow()) { throw new RuntimeException(已达到最大借书数量); } // 2. 校验图书可借数量原子更新 Book book bookMapper.selectById(bookId); if (book null) { throw new RuntimeException(图书不存在); } int updated bookMapper.decreaseAvailable(bookId); if (updated 0) { throw new RuntimeException(图书已借完); } // 3. 插入借阅记录 BorrowRecord record new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setBorrowTime(new Date()); record.setStatus(0); borrowRecordMapper.insert(record); } Transactional(rollbackFor Exception.class) public void returnBook(Long recordId) { BorrowRecord record borrowRecordMapper.selectById(recordId); if (record null || record.getStatus() ! 0) { throw new RuntimeException(借阅记录状态异常); } record.setStatus(1); record.setReturnTime(new Date()); borrowRecordMapper.updateStatus(record); // 归还后恢复可借数量 bookMapper.increaseAvailable(record.getBookId()); } }Mapper里对应两个原子更新SQLupdate iddecreaseAvailable UPDATE book SET available available - 1 WHERE id #{id} AND available 0 /update update idincreaseAvailable UPDATE book SET available available 1 WHERE id #{id} /update这段代码有四个关键点。第一decreaseAvailable里的AND available 0是防止超借的防线即使并发下两个请求同时读到available1数据库行锁也会让其中一个更新失败。第二update返回int等于0说明没有更新到行所以可以断定“没借到”。第三整个方法加上Transactional(rollbackFor Exception.class)注意rollbackFor必须指定因为Spring默认只回滚RuntimeException而如果Exception异常抛出来事务不会回滚会造成“库存扣了但记录没插入”的脏数据。第四还书时先理清状态再增库存这个顺序不能反否则如果在更新还书记录后、增加库存前系统崩溃会出现记录已还但库存没恢复的问题把两步放在同一个事务里崩溃也会一起回滚。5. 避坑/常见问题排查环境、编码、事务与SQL的四个典型翻车现场5.1 现象启动Tomcat后访问404控制台没有错误war包解压后只有空目录这个坑在Maven新手身上特别常见。原因是IDEA或Eclipse里部署到Tomcat时依赖的jar包没有输出到WEB-INF/lib。你看到Tomcat能启动Spring的xml也没报错是因为类加载还没有触发到需要jar包的环节。解决方法是检查项目的ArtifactsIDEA里叫这个。在“Project Structure - Artifacts”里选中你的war exploded右侧“Available Elements”里把lib目录加入WEB-INF/lib。如果用的是mvn tomcat:run这种插件方式则要确认pom.xml里没有把依赖范围设成provided当然servlet-api除外。这个坑的排查技巧是访问时看Tomcat日志的INFO级别如果看到Deploying web application archive后直接跳成功但目录里没有jar就是这个问题。5.2 现象页面中文全部是问号URL里手动传中文参数能显示但表单POST之后乱码问题出在编码过滤器没有覆盖POST请求。你很可能只配置了request.setCharacterEncoding(UTF-8)但没放在过滤器里或者过滤器映射的url-pattern写成了/而不是/*。/在Servlet映射里匹配的是路径里的最后一个斜杠不匹配带参数的子路径只有/*才能拦住所有请求。另一个隐蔽点是MySQL连接串必须加characterEncodingutf8否则即使Java侧是UTF-8JDBC驱动和MySQL交互时也可能转成latin1再转回中文照样乱。确保同时满足CharacterEncodingFilter的forceEncodingtrue、连接串上加characterEncodingutf8、数据库表和字段都是utf8mb4。5.3 现象借书按钮点击后前端提示成功但数据库里借阅记录没有插入再查库存也少了这是典型的“事务没生效”症状。原因极可能是Transactional被加在了Controller层或者Service方法内部通过this调用另一个方法——比如在borrowBook()里调了一个本类的checkAndInsert()Spring的AOP默认只在外部调用时创建代理内部this调用绕过了代理事务注解就被忽略了。解决把事务注解加到Service类的public方法上并且确保是通过Spring注入的Bean来调用不能在类内部用this。如果采用了Spring Boot可以用Transactional的proxyTargetClasstrue强制CGLIB代理但最好还是遵守“类外部调用”这条规则。5.4 现象分页查询时第一页正常第二页数据却和第1页重复或者点击第3页后总页数变成0PageHelper的坑在于它使用ThreadLocal保存分页参数然后拦截下一次查询。如果你的Service方法里先执行了别的查询比如在startPage()之后又去查了用户信息那么分页参数就会作用到用户查询上导致图书查询没有分页或者错误分页。此外如果你用了PageHelper.startPage(pageNum, pageSize)但后面跟着的是一条update语句分页参数不会生效但也不会报错容易让人误以为没问题。正确的做法是startPage()紧贴着你要分页的那条select中间不要穿插任何其他查询。如果用了MyBatis的Select注解而非XML同样受影响。5.5 现象系统运行一段时间后页面偶尔报“连接超时”或“Too many connections”重启Tomcat才恢复根因是连接池没有配置最大连接数和超时时间。很多人用了默认的Druid或HikariCP配置但忘记设置maxActive。在SSM环境如果用Druid标准配置是initialSize: 5, minIdle: 5, maxActive: 20, maxWait: 60000注意maxWait单位是毫秒60000表示等待6秒拿不到连接就抛异常。如果应用并发不高但每次请求都慢问题往往不是连接池而是数据库连接没有释放——检查所有Mapper方法是否都通过Spring管理了事务有没有人直接写了SqlSession手动开关。在SSM里只要Service方法标了事务连接会在方法结束后释放但如果有人在代码里直接用Autowired的SqlSessionTemplate并手动commit就非常容易漏关。统一走Mapper接口不要手动操作SqlSession。6. 把这套系统从“能跑”做到“能演示”数据初始化、日志与验收自测清单6.1 用SQL脚本初始化演示数据管理员账号与样例图书演示系统最怕打开页面是空表所以要准备一份data.sql。管理员账号的密码不能用明文我用BCrypt生成一个固定值然后把哈希值写入脚本INSERT INTO user (username, password, role, max_borrow) VALUES (admin, $2a$10$iK5YhZA3wBqzOz9L7yJ7UuY9JcY1x2QkG9wXm7zvXkXo5F3bNm9Kq, 1, 100);这个哈希对应的是admin123但你在数据库里看不出来答辩时可以拿这点讲“我没存明文密码”。图书样例要有一点情节放入《Java核心技术》这种跟主题相关的书库存设成3可借数设成3再放一本《深入理解Java虚拟机》之类的让借书时有真实的库存变化可看。初始化脚本里还要注意清空已有表时的顺序先删borrow_record、再删book、user因为有外键约束时顺序反了会报错。6.2 给关键操作加上日志用AOP记录谁在什么时候借了什么书图书管理系统在答辩时亮点通常体现在日志上。我用Spring AOP拦截借还操作把操作记录打到日志文件里。先定义一个切面package com.example.book.aspect; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterReturning; import org.aspectj.lang.annotation.Aspect; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; Aspect Component public class BorrowLogAspect { private static final Logger logger LoggerFactory.getLogger(BorrowLogAspect.class); AfterReturning(execution(* com.example.book.service.BorrowService.borrowBook(..))) public void logBorrow(JoinPoint joinPoint) { Object[] args joinPoint.getArgs(); logger.info(用户ID {} 借阅图书ID {}, args[0], args[1]); } AfterReturning(execution(* com.example.book.service.BorrowService.returnBook(..))) public void logReturn(JoinPoint joinPoint) { Object[] args joinPoint.getArgs(); logger.info(借阅记录ID {} 还书, args[0]); } }注意这里只能打日志不能把业务逻辑放在AOP里做。如果AOP抛异常会干扰原方法的执行。日志级别用info可以从日志文件里摘出所有借书动作方便答辩时演示“系统记录了每一笔操作”。如果你没有引入spring-aspectj依赖AOP不生效。另一个办法是在Service方法里直接打日志简单有效不过AOP的好处是以后你在Controller层调服务时日志是全局的不用每调一次写一遍。6.3 验收自测一个包含功能路径的检查清单系统做完我会按下面的清单走一遍每项都标注“通过/失败”管理员登录输入错误密码能提示“用户名或密码错误”输入正确密码能跳转到管理首页。新增图书字段不填书名会校验报错填完整后列表出现新书库存数和可借数一致。借书全流程用普通用户账号登录借一本库存只剩1本的书能成功再点一次借同一本书提示“图书已借完”或“已达最大借书数”。还书流程借出后库存可借数减少1还书后增加1列表恢复。并发测试开两个浏览器用两个不同用户同时借同一本书且这本书库存只有1本只允许一个成功。这个可以在答辩时用两个窗口演示非常出效果。分页边界每页大小为10总页数28条时第3页有8条页码导航正确显示1、2、3。空白数据场景清空图书表后列表页不报空指针而是显示“暂无数据”。最后一点很容易被忽略。我在最初写列表时忘了判断pageInfo.getList().isEmpty()结果空表时JSP的c:forEach确实不报错但分页导航里的pageInfo.pages是0页面上会出现“第0/0页”看起来很业余。需要在Controller或JSP里加判断总记录数为0时给model加一个emptyTip字段。这个小细节往往是答辩时加分的地方。做这个系统的过程里我最大的教训是“越觉得简单的地方越要手写清楚”。事务注解和编码过滤器这两个配置我第一次做的时候也翻过车后来把每一次启动异常记录在笔记里这个项目的排错效率才真正提上来。希望这份方案帮你少走几步弯路把图书管理系统做成一个能展示、能扛住提问的完整作品。本文还有配套的精品资源点击获取