简介一份基于 SpringBoot、MyBatis 与 JSP 构建的图书管理系统源码包面向 Java Web 初学者、课程设计或毕业设计开发者帮助其完成从框架整合到业务落地的完整实践。系统围绕图书增删改查、分类管理、借阅归还与分页展示展开采用 MVC 分层组织代码并涉及 Spring Security 权限控制、MyBatis Mapper 映射及可执行 jar 部署等常见环节。压缩包共 1192 个文件、约 95.3MB包含 648 个 class、173 个 jsp、148 个 xml、65 个 jar 与 51 个 java 等分别对应编译产物、页面视图、配置与依赖、业务源码便于直接运行和对照学习。已有 2208 人学习下载。读者可据此梳理 SpringBoot 与 MyBatis 的整合流程、理解数据库交互与分页实现并参考现有页面和配置快速搭建自己的图书管理项目适合作为入门实战与课程作业的参照。1. 需求先摆正库存数字在任何时刻都要对得上图书管理系统的需求从来不复杂复杂的是怎么让一本书的可借数量在借出、归还、超期、下架这些动作之后依然准确。用 SpringBoot MyBatis JSP 做图书管理系统前后端不分离、页面服务端渲染看着像老派做法实际是课设与毕设里最稳的一条路调用链短、报错位置明确、一个 war 包丢上去就能跑。这套方案适合两类人一类是要交一份能演示、能讲清设计的学生另一类是需要快速交付一套内部借阅工具的开发。下面按需求、架构、实现、排查、部署、验收的顺序拆开讲。2. 总体架构与数据模型三层结构、六个模块和六张核心表很多人一上来就写 Controller写到第三天发现 Service 里塞满了参数校验、事务注解和 HTTP 相关的对象改一处牵动三处。架构这件事在图书管理系统里不玄学先把职责切干净后面所有代码都有地方放。2.1 分层与模块边界Controller 只管参数事务一律放 Service整体是经典四层controller接请求、service承载业务与事务、mapper只写 SQL、entity/dto/vo负责数据搬运。六个业务模块按这条线各走一遍图书管理、读者管理、借阅管理、分类管理、系统用户与权限、统计报表。职责划分我一般这么定Controller 做三件事——接参数、调 Service、决定跳哪个 JSP 或返回什么 JSON。不写业务判断不碰SqlSession。Service 是事务边界一个业务动作一个方法方法内部要么全成功要么全回滚。借阅、归还、续借都在这层。Mapper 只放 SQL不在 XML 里写if (status 1)这种 Java 逻辑判断条件用if传参解决。实体分三种entity对应表、dto接前端表单、vo给页面渲染比如图书列表要带分类名称就该是BookVO而不是Book。这样切的好处很直接答辩时评委问「库存扣减在哪」你能一句话答出「在 BorrowServiceImpl.borrow 里和借阅记录插入在同一个事务」。翻车场景也少改页面不用动 SQL改 SQL 不用动页面。统计报表模块单独说明一下。它不新建表全部从借阅记录聚合近 30 天借阅量、分类占比、超期未还清单。查询写在ReportMapper.xml里用GROUP BY直接出结果页面用 JSTL 循环渲染表格不引图表库也能交差。2.2 依赖清单pom 里必须钉死的四项JSP 放在 Spring Boot 里最容易踩的坑就是依赖没引全页面被当成静态文件直接下载。下面这段是 pom 的关键部分注释标了每一项的作用!-- 打包方式必须是 warjar 包下内嵌 Tomcat 默认不解析 JSP -- packagingwar/packaging dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- JSP 解析器缺了它页面会以源码形式被下载 -- dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-jasper/artifactId /dependency !-- JSTL 标签库页面里的 c:forEach、fmt:formatDate 靠它 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- MyBatis 整合包含 SqlSessionFactory 自动装配与事务桥接 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- 分页插件后台所有列表页共用 -- dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.6/version /dependency /dependencies版本选择上有个绕不开的决定Spring Boot 2.7.x 维持在javax.servlet命名空间JSTL 用 1.2 即可如果换成 3.x整个 servlet 体系迁到jakartaJSTL 依赖坐标和页面里的标签 uri 都要跟着换一处没改就是 500 报错。做课设或毕设建议直接锁 2.7.x把精力放在业务上而不是迁移上。这一点属于血泪经验迁移带来的收益在这类项目里几乎为零风险却是连环的。配置文件对应写清楚视图前缀、数据源和 MyBatis 扫描路径server: port: 8080 servlet: context-path: /library # 有上下文路径页面里所有链接都要带上前缀 encoding: charset: UTF-8 force: true # 强制请求与响应都用 UTF-8挡住大部分乱码 spring: mvc: view: prefix: /WEB-INF/jsp/ # 故意放在 WEB-INF 下浏览器无法直接访问 suffix: .jsp datasource: url: jdbc:mysql://127.0.0.1:3306/library_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: library password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.library.entity configuration: map-underscore-to-camel-case: true # book_id 自动映射到 bookId省掉大量 resultMap log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 调试期把 SQL 打到控制台 pagehelper: helper-dialect: mysql reasonable: true # 页码越界时回到合理范围不返回空列表 support-methods-arguments: truecontext-path这一项要特别注意一旦设了/libraryJSP 里所有href和src都要用${pageContext.request.contextPath}拼否则上线后全站 404。这是新手最常翻的一次车本地不加 context-path 测得好好的部署到外部 Tomcat 就崩。2.3 数据模型六张表怎么切库存字段要不要冗余六张核心表sys_user系统用户、reader读者、book图书、category分类、borrow_record借阅记录、fine_rule超期规则可并为配置表。外键在业务系统里通常不建物理约束靠应用层保证避免删改时被数据库卡住这一点在演示环境里尤其重要。表名作用关键字段索引book图书主数据与库存isbn、title、total_copies、available_copiesuk_isbn、idx_title、idx_categoryreader读者证与借阅额度card_no、name、max_borrow、loan_days、statusuk_card_noborrow_record借阅流水与状态机book_id、reader_id、due_date、return_date、statusidx_reader_status、idx_bookcategory分类树name、parent_id、sortidx_parentsys_user管理员与操作员username、password、roleuk_usernamefine_rule超期罚金规则reader_type、free_days、fee_per_dayidx_type库存字段是这张设计里唯一需要争论的地方。我倾向于冗余book表同时存total_copies和available_copies列表页直接读不用每次COUNT借阅记录。代价是写入时必须同步维护所以扣减一律走一条带条件的UPDATE-- 图书主表库存双字段列表页零聚合查询 CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL, title VARCHAR(120) NOT NULL, author VARCHAR(60) NOT NULL, category_id BIGINT NOT NULL, total_copies INT NOT NULL DEFAULT 1, available_copies INT NOT NULL DEFAULT 1, location VARCHAR(30) DEFAULT NULL COMMENT 馆藏位置如 A区-3排, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 在架 0 下架, UNIQUE KEY uk_isbn (isbn), KEY idx_title (title), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 借阅记录status 是有限状态机超期由 due_date 与 return_date 派生 CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, reader_id BIGINT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, return_date DATE DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 借出 2 已还 3 超期未还, renew_count TINYINT NOT NULL DEFAULT 0 COMMENT 续借次数上限 1 次, operator_id BIGINT DEFAULT NULL, KEY idx_reader_status (reader_id, status), KEY idx_book (book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;有个常见的错误设计想提醒一句有人用UNIQUE KEY (reader_id, book_id, status)来防止同一读者重复借同一本书。这条约束只在「一辈子只能借一次」时成立读者还了再借就会直接插入失败。正确做法是查询时加status IN (1,3)判断是否已有未还记录把并发兜底交给库存的条件更新。3. 核心业务落地检索分页、借还事务、权限拦截三条主线功能列表看着长真正有技术含量、也最容易被追问的只有三条线查得准、借得对、拦得住。把这三条写扎实剩下的增删改都是体力活。3.1 图书检索与分页动态 SQL 拼条件PageHelper 管翻页列表页的需求通常是关键词书名/作者/ISBN 模糊、分类筛选、只看有货、分页。这类查询最忌讳写四条 SQL 分支用 MyBatis 的where加if一条搞定!-- BookMapper.xml条件全部可选前端传空就不参与拼接 -- select idselectByCondition resultTypecom.example.library.vo.BookVO SELECT b.id, b.isbn, b.title, b.author, b.total_copies, b.available_copies, b.location, b.status, c.name AS categoryName FROM book b LEFT JOIN category c ON c.id b.category_id where if testkeyword ! null and keyword ! AND (b.title LIKE CONCAT(%, #{keyword}, %) OR b.author LIKE CONCAT(%, #{keyword}, %) OR b.isbn #{keyword}) /if if testcategoryId ! null AND b.category_id #{categoryId} /if if testonlyAvailable ! null and onlyAvailable AND b.available_copies gt; 0 /if if teststatus ! null AND b.status #{status} /if /where ORDER BY b.id DESC /select两个细节值得单独讲。第一XML 里的大于号要写成gt;直接写会让整个 mapper 解析失败报的错还指向文件头看着莫名其妙。第二resultType用BookVO而不是Book靠categoryName这个别名完成关联字段填充省掉一个resultMap的配置量。Service 层的分页调用有一个硬性顺序要求Override public PageInfoBookVO page(BookQuery query, int pageNum, int pageSize) { // startPage 把分页参数存进 ThreadLocal必须紧贴下面的查询 PageHelper.startPage(pageNum, pageSize); ListBookVO list bookMapper.selectByCondition(query); // PageInfo 会顺带算出总条数、总页数、是否有上一页下一页 return new PageInfo(list); }startPage和查询之间不能插入任何其它数据库操作否则分页参数会被前一次查询消耗掉表现是「总数对、列表错」或者「永远只返回第一页」。这是分页插件最经典的失效方式代码看上去完全正常。3.2 借阅与归还一次事务里的库存扣减与记录写入借阅是整个系统里唯一必须保证原子性的动作库存减一和流水插入要么一起成功要么一起失败。我把校验放在最前扣减用条件更新最后写流水Override Transactional(rollbackFor Exception.class) // 必须带上否则受检异常不回滚 public void borrow(Long bookId, Long readerId, Long operatorId) { Reader reader readerMapper.selectById(readerId); if (reader null || reader.getStatus() ! 1) { throw new BizException(读者证状态异常无法借阅); } int borrowing recordMapper.countActive(readerId); // 已借未还数量 if (borrowing reader.getMaxBorrow()) { throw new BizException(已达可借上限 reader.getMaxBorrow() 册); } // 条件更新库存为 0 时影响行数是 0天然挡住并发超借 int rows bookMapper.decreaseStock(bookId); if (rows 0) { throw new BizException(该书暂无可借副本); } BorrowRecord record new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowDate(LocalDate.now()); record.setDueDate(LocalDate.now().plusDays(reader.getLoanDays())); record.setStatus(1); record.setOperatorId(operatorId); recordMapper.insert(record); }对应的扣减 SQL 只有一句但它是整个并发安全的支点update iddecreaseStock UPDATE book SET available_copies available_copies - 1 WHERE id #{bookId} AND available_copies gt; 0 AND status 1 /update为什么不用「先SELECT查库存再UPDATE减一」因为两步之间可能被另一个请求插进来两个人同时看到库存 1都判断可借最后库存变成 -1。把判断条件塞进WHERE里由数据库的行锁保证同一行的更新串行执行影响行数为 0 就代表抢失败了直接抛异常回滚。这套写法在单机和多实例下都成立比在 Java 里加synchronized靠谱得多。归还的逻辑反过来先校验记录状态再回补库存最后更新流水Override Transactional(rollbackFor Exception.class) public void returnBook(Long recordId) { BorrowRecord record recordMapper.selectById(recordId); if (record null || record.getStatus() 2) { throw new BizException(该借阅记录不存在或已归还); } LocalDate today LocalDate.now(); record.setReturnDate(today); // 超过应还日期记为超期页面据此计算罚金 record.setStatus(today.isAfter(record.getDueDate()) ? 3 : 2); recordMapper.updateReturn(record); bookMapper.increaseStock(record.getBookId()); }status 3在这里表示「超期后归还」逾期天数由return_date - due_date现算不落库。这样规则改了比如免罚天数从 0 天改成 3 天只需要改计算逻辑不用刷数据。3.3 登录与角色权限拦截器加白名单够用且好讲图书管理系统不需要 Spring Security 那一套一个拦截器加会话就够还能在答辩时讲清楚原理。核心是登录拦截public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(false); Object user session null ? null : session.getAttribute(loginUser); if (user null) { // Ajax 请求返回 401 让页面自己跳普通请求直接重定向到登录页 if (XMLHttpRequest.equals(request.getHeader(X-Requested-With))) { response.setStatus(401); return false; } response.sendRedirect(request.getContextPath() /login); return false; } return true; } }注册时把静态资源和登录接口放进白名单否则 CSS 也会被拦下来导致页面裸奔Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /doLogin, /css/**, /js/**, /images/**, /error); } }角色区分用路径前缀做粗粒度控制/admin/**只允许role1的管理员访问在拦截器里再读一次 session 中的角色判断即可。读者端和管理端共用一个登录入口登录成功后按角色决定跳哪个首页。密码存储不要明文用BCryptPasswordEncoder或至少MD5 盐这一条几乎是答辩必问项。3.4 视图层写法JSTL 渲染 表单回填JSP 页面只做渲染不写% %脚本片段。列表页的典型写法% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % table classtable thead trth书名/thth作者/thth可借/总数/thth操作/th/tr /thead tbody c:forEach items${pageInfo.list} varbook tr td${book.title}/td td${book.author}/td td${book.availableCopies} / ${book.totalCopies}/td td c:choose c:when test${book.availableCopies 0} a href${pageContext.request.contextPath}/borrow?bookId${book.id}借阅/a /c:when c:otherwisespan classdisabled暂无库存/span/c:otherwise /c:choose /td /tr /c:forEach /tbody /tablepageInfo.list是后端model.addAttribute(pageInfo, pageInfo)传进来的翻页条直接读pageInfo.pages和pageInfo.pageNum循环生成页码。所有链接统一用${pageContext.request.contextPath}打头是保证本地和部署环境都能跳转的关键。提示JSP 里${}默认不过滤 HTML图书简介这类用户可填字段要加c:out value${book.remark}/或fn:escapeXml不然就是现成的 XSS 演示。4. 常见问题排查JSP 404、事务不回滚、乱码这几类翻车点这套技术组合的问题高度集中翻来覆去就那几类。下面按现象、原因、解决三步写遇到时照着对一遍比漫无目的搜要快。4.1 视图层页面打不开与静态资源丢失现象一访问接口后浏览器直接下载了一个.jsp文件或者报 404。原因通常有三个项目打成 jar 包内嵌 Tomcat 只把 JSP 当静态资源返回RestController用在了本该返回页面的 Controller 上视图前缀没以/开头解析出来的路径不对。 解决pom.xml改packagingwar/packaging并引入tomcat-embed-jasper页面跳转的类改用Controllerspring.mvc.view.prefix写成/WEB-INF/jsp/注意首尾的斜杠。另外用外部 Tomcat 部署时启动类要继承SpringBootServletInitializer并重写configure否则 war 起来后找不到任何映射。现象二页面能打开但样式全丢控制台一堆 404。原因是配了context-path之后页面里仍写着href/css/app.css这种绝对路径或者拦截器把/css/**也拦了。 解决链接统一用${pageContext.request.contextPath}/css/app.css拦截器白名单里补上/css/**、/js/**、/images/**、/favicon.ico。4.2 数据层事务失效与分页错乱现象三借阅失败抛了异常库存却已经被扣掉了。原因有三种常见形态Transactional加在同类内部被this调用的方法上绕过了 Spring 代理方法不是public异常在方法里被try-catch吞掉或者抛的是受检异常但没写rollbackFor。 解决把事务方法定义在 Service 接口实现里并由外部调用显式写Transactional(rollbackFor Exception.class)不要在事务方法内部吞异常需要记录日志就记完再抛出去。这一条我见过太多次报错信息干净得很问题全在调用路径上。现象四分页查出来的总数是对的列表却是全量数据。原因是PageHelper.startPage()之后、真正查询之前插入了别的数据库调用比如先查了一次读者信息分页参数被那次查询消耗掉了。 解决startPage紧贴目标查询中间不插任何 mapper 调用返回时用new PageInfo(list)包装不要自己算总数。4.3 运行环境乱码、时区与打包现象五中文显示成问号或者借阅时间比实际早了 8 小时。原因是链路三处编码不统一JDBC url 没带characterEncodingutf8server.servlet.encoding.force没开JSP 的pageEncoding与contentType不一致。时区问题则是 MySQL 连接没指定serverTimezone。 解决数据库、表、连接三处统一utf8mb4url 补上characterEncodingutf8serverTimezoneAsia/ShanghaiJSP 页头写全contentTypetext/html;charsetUTF-8 pageEncodingUTF-8。这三处对齐后乱码问题基本绝迹。注意外部 Tomcat 部署时访问路径由 war 包名决定。想让系统跑在根路径把 war 改名成ROOT.war同时确认context-path不要和它冲突。5. 环境搭建与部署从本地跑通到 war 包上线的一份清单代码写完之后能不能在另一台机器上从零跑起来是检验这套方案完整度的硬指标。这一章给一份可照着执行的清单。5.1 本地环境与建库脚本的执行顺序本地需要 JDK 8 或 11、Maven 3.6、MySQL 8。脚本分成两个文件schema.sql建表建索引data.sql灌基础数据分类、管理员账号、超期规则演示用的图书和借阅记录单独一份demo.sql便于答辩前重置。# 建库字符集必须是 utf8mb4否则书名里的生僻字和 emoji 会插入失败 mysql -u root -p -e CREATE DATABASE library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 顺序不能反先结构再基础数据最后演示数据 mysql -u root -p library_db sql/schema.sql mysql -u root -p library_db sql/data.sql mysql -u root -p library_db sql/demo.sql # 启动开发环境 mvn spring-boot:run -Dspring-boot.run.profilesdevdata.sql里至少要有一个管理员账号、一个操作员账号、三个读者、五个分类、二十本图书其中至少两本available_copies设为 1专门用来演示「最后一本被借走」。演示数据的质量直接影响验收观感别拿一堆「测试1」「测试2」凑数。5.2 打包与两种部署方式的选择# 打包产物是 war 不是 jar mvn clean package -DskipTests # 方式一丢进外部 Tomcat路径由 war 包名决定 cp target/library.war /opt/tomcat/webapps/ # 方式二直接运行内嵌 Tomcat 配合 jasper 同样能解析 JSP java -jar target/library.war --spring.profiles.activeprod两种方式都可行差别在于外部 Tomcat 需要把spring-boot-starter-tomcat的 scope 设为provided并让启动类继承SpringBootServletInitializer直接java -jar则不需要这些但要求 JSP 相关依赖都在 war 内。答辩现场建议用java -jar起一条命令、不依赖外部容器出问题的概率最低。5.3 配置分环境与答辩演示数据application.yml只放公共配置application-dev.yml和application-prod.yml各自覆盖数据源用spring.profiles.active切换。这样本地连自己的库演示环境连演示库互不干扰。演示前建议执行一次重置脚本把借阅记录清空、库存恢复、把某条记录的due_date改到三天前用于展示超期。这类准备看着琐碎但它决定了演示时能不能一句话把超期规则讲明白而不是现场改数据库。6. 验收与答辩用一条借还链路把整套设计讲圆这套系统功能不少但答辩只有十几分钟什么都讲等于什么都没讲。我的建议是只演示一条完整链路登录 → 检索 → 借出 → 库存变化 → 超期展示 → 归还每一步都对应一个设计决策。这条链路串起来架构、事务、权限、页面渲染全部覆盖到了评委追问也有落点。准备一张验收表自己先走一遍每一条都过验收项演示动作预期结果登录与拦截未登录直接访问图书列表地址跳转登录页登录后回到原页面检索分页输入关键词并翻到第二页结果只含匹配项总条数与总页数正确借阅扣减借出一本库存为 1 的书可借数减 1借阅记录新增一条并发兜底对同一本书连续快速点两次借阅第二次提示无可借副本无脏记录归还回补归还该书可借数恢复原值状态改为已还超期判定把应还日期改到昨天后刷新列表标红并显示逾期天数讲解顺序上先给一张架构图讲清楚六层模块的职责再挑借阅这个动作往下钻为什么库存要冗余、为什么扣减用条件更新而不是先查后改、为什么事务放在 Service 而不是 Controller。把这三问答完整基本就没人再纠结你用的是 JSP 还是模板引擎了。反而如果一上来就讲页面多漂亮最后往往被问「两个人同时借最后一本会怎样」而答不上来。还有一个细节所有校验失败、库存不足、超期归还的提示语要提前设计好别出现NullPointerException的堆栈直接弹到页面上。演示环境里崩一次后面讲得再好也救不回来。我第一次带这类项目验收时就是因为演示前没跑一遍重置脚本讲到归还环节发现库存已经对不上当场花了五分钟查数据。从那以后我每次演示前都强制走一遍完整链路从登录到归还一条不落跑通了才敢上台。希望帮到你。本文还有配套的精品资源点击获取