☰
SpringBoot+MyBatis+JSP图书管理系统实战:从建表到事务处理
2026/10/14 4:04:19 网站建设 项目流程

简介:这是一套基于SpringBoot、MyBatis与JSP构建的图书管理系统完整项目源码,面向具备Java Web基础、希望深入理解企业级开发流程的开发者与在校学生。系统涵盖图书增删改查、分类管理、借阅归还、分页查询等核心业务,并采用MVC架构与Spring Security实现权限控制,适合作为课程设计、毕业设计或技术练手的参考案例。资源包共1192个文件,约95.3MB,其中648个class为编译产物,173个jsp负责视图渲染,148个xml承载配置与SQL映射,另有51个java源码、65个jar依赖及js、css等前端资源,结构完整、层次清晰。目前已有2207人学习下载。通过研读源码,读者可掌握SpringBoot自动配置、MyBatis持久层整合、JSP动态页面渲染及分页插件使用等关键技能,并借鉴其前后端分离设计思路与部署方式,快速积累Java Web项目实战经验。

1. 从一张借书单说起:springboot + mybatis + jsp 的图书管理系统到底在解决什么

图书馆里最原始的借阅流程,是一张纸质借书单加一本登记簿。读者填单、管理员核对、手写登记、归还时再翻本子划掉。这套流程在藏书几百本时还能转,一旦上到几万册、每天几百次借还,登记簿就成了黑匣子——谁借走了、什么时候到期、有没有超期,全靠人翻。springboot + mybatis + jsp 的图书管理系统,本质就是把这张借书单和登记簿搬进数据库,用浏览器当窗口,让借还、查询、续借、超期提醒变成几次点击。

这套技术组合是很多高校课程设计和中小企业内部工具的主流选型:SpringBoot 负责把 Web 容器、依赖注入、事务管理这些脏活打包好,MyBatis 负责把 Java 对象和数据库表之间的映射写清楚,JSP 负责在服务端把数据渲染成页面。它不追求前后端分离的时髦,胜在结构直白、上手快、部署简单,一台普通服务器甚至一台开发机就能跑起来。适合谁?适合需要快速交付一个可用的内部管理系统、又不想被前端工程化拖住节奏的开发者,也适合想完整走一遍「控制器—服务—持久层—视图」链路的练手者。下面我按实际落地的顺序,把选型、建表、映射、页面、排错和进阶一条条拆开。

2. 选型与骨架:为什么是 SpringBoot + MyBatis + JSP,而不是别的组合

2.1 三件套各自的职责边界

先把三个技术点的分工说清楚,后面写代码才不会乱。SpringBoot 是容器和装配层,它管的是「谁来 new 对象」「事务什么时候提交」「请求打到哪个方法」。MyBatis 是持久层,它管的是「这条 SQL 怎么写」「结果怎么塞进 Java 对象」。JSP 是视图层,它管的是「数据怎么变成 HTML 发给浏览器」。

很多人一上来就把业务逻辑写进 JSP 的 scriptlet 里,<% ... %>里连数据库,这是最常见的翻车起点。正确边界是:JSP 只做展示和表单提交,Controller 接参数做校验,Service 写业务规则,Mapper 只碰 SQL。这条线守住了,后面加功能才不会牵一发动全身。

选这套组合而不是 SpringBoot + Vue 的理由很实际:JSP 在服务端渲染,不需要单独的前端构建、打包、跨域配置,一个 jar 包丢上去就能跑。对于图书管理这种以表单和列表为主的系统,页面交互复杂度低,前后端分离带来的收益有限,反而增加了部署和联调成本。

2.2 用 Spring Initializr 生成可运行骨架

第一步不是急着写业务,而是先让一个空项目跑起来。用 Spring Initializr 或者 IDE 自带的向导生成骨架,依赖勾选 Spring Web、MyBatis Framework、MySQL Driver,打包方式选 jar。生成后目录结构大致是标准的 Maven 布局。

# 项目根目录下执行,确认骨架能编译 mvn clean package -DskipTests # 启动,默认端口 8080 java -jar target/book-manager-0.0.1-SNAPSHOT.jar

这两条命令的作用是先验证「空壳能不能跑」。如果这一步就报错,多半是 JDK 版本和 SpringBoot 版本不匹配,或者 Maven 仓库没配好。参数上,-DskipTests是跳过测试加速打包,正式构建时不要带。启动成功后访问http://localhost:8080,看到 Whitelabel 错误页说明容器起来了,只是没有映射根路径,这是正常的。

2.3 让 JSP 在 SpringBoot 里正常渲染

SpringBoot 默认不推荐 JSP,所以需要额外配置。核心是三件事:加依赖、配前缀后缀、把 JSP 放到正确目录。

<!-- pom.xml 中补充 JSP 相关依赖 --> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>
# application.properties spring.mvc.view.prefix=/WEB-INF/jsp/ spring.mvc.view.suffix=.jsp

依赖说明:tomcat-embed-jasper是内嵌 Tomcat 的 JSP 编译引擎,没有它 JSP 不会被解析;jstl提供<c:forEach>这类标签,列表渲染全靠它。配置说明:prefix和suffix决定了 Controller 返回"book/list"时实际去找/WEB-INF/jsp/book/list.jsp。注意 JSP 必须放在src/main/webapp/WEB-INF/jsp/下,放在resources里是找不到的,这是新手第一个高频坑。

3. 数据库与 MyBatis 映射:把借阅规则落成表和 SQL

3.1 图书管理系统的四张核心表

表设计决定了后面 SQL 的复杂度。图书管理系统最小可用模型需要四张表:图书表、读者表、借阅记录表、管理员表。借阅记录表是核心,它连接图书和读者,并承载借出时间、应还时间、归还时间、状态这些业务字段。

CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(100) NOT NULL, author VARCHAR(50), total_copies INT DEFAULT 1, -- 馆藏总数 available_copies INT DEFAULT 1, -- 可借数量 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); 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, due_time DATETIME NOT NULL, return_time DATETIME, status TINYINT DEFAULT 0 -- 0借出 1已还 2超期 );

字段说明:available_copies是冗余字段,用空间换查询效率,借出时减一、归还时加一,避免每次查可借数量都去 count 借阅记录。status用整型而不是字符串,索引效率更高。due_time在借出时就计算好写入,而不是查询时动态算,这样超期判断只需要比较当前时间和due_time。

3.2 MyBatis 的 XML 映射与动态 SQL

MyBatis 有两种写法:注解和 XML。图书管理系统的查询条件往往不固定——按书名查、按作者查、按状态查,这种场景 XML 的动态 SQL 更合适。

<!-- BookMapper.xml --> <select id="selectByCondition" resultType="com.demo.entity.Book"> SELECT * FROM book <where> <if test="title != null and title != ''"> AND title LIKE CONCAT('%', #{title}, '%') </if> <if test="author != null and author != ''"> AND author = #{author} </if> <if test="onlyAvailable"> AND available_copies &gt; 0 </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>

逻辑说明:<where>标签会自动处理第一个条件前的AND,避免拼接出WHERE AND这种语法错误。#{}是预编译占位符,能防 SQL 注入;${}是字符串直接拼接,除非是排序字段这种无法预编译的场景,否则不要用。参数说明:onlyAvailable是布尔值,控制是否只查可借图书;offset和pageSize配合实现分页,offset = (页码 - 1) * pageSize。

3.3 借书与还书的事务处理

借书这个动作涉及两张表的写操作:借阅记录插入一条,图书可借数量减一。这两步必须在一个事务里,否则可能出现记录插了但库存没减,或者库存减了记录没插。

@Service public class BorrowService { @Autowired private BookMapper bookMapper; @Autowired private BorrowRecordMapper recordMapper; @Transactional(rollbackFor = Exception.class) public void borrow(Long bookId, Long readerId) { // 先扣库存,用带条件的更新防止超借 int affected = bookMapper.decreaseAvailable(bookId); if (affected == 0) { throw new RuntimeException("库存不足,借阅失败"); } BorrowRecord record = new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowTime(new Date()); record.setDueTime(DateUtil.plusDays(new Date(), 30)); record.setStatus(0); recordMapper.insert(record); } }
<update id="decreaseAvailable"> UPDATE book SET available_copies = available_copies - 1 WHERE id = #{bookId} AND available_copies &gt; 0 </update>

逻辑说明:decreaseAvailable的 WHERE 条件里带了available_copies > 0,这是乐观锁思路——如果库存已经为 0,更新影响行数为 0,Service 层据此抛异常回滚。这样即使两个请求同时借同一本书,也只有一个能成功。参数说明:@Transactional的rollbackFor = Exception.class确保任何异常都触发回滚,默认只回滚运行时异常,受检异常不会回滚,这是容易忽略的点。

4. JSP 页面与交互:把数据渲染成能点的界面

4.1 图书列表页的分页与条件查询

列表页是使用频率最高的页面。Controller 接收查询参数和页码,Service 调 Mapper 拿数据,JSP 用 JSTL 循环渲染。

@Controller @RequestMapping("/book") public class BookController { @Autowired private BookService bookService; @GetMapping("/list") public String list(@RequestParam(defaultValue = "") String title, @RequestParam(defaultValue = "1") Integer page, Model model) { int pageSize = 10; List<Book> books = bookService.query(title, page, pageSize); int total = bookService.count(title); model.addAttribute("books", books); model.addAttribute("totalPages", (total + pageSize - 1) / pageSize); model.addAttribute("currentPage", page); model.addAttribute("title", title); return "book/list"; } }

参数说明:@RequestParam的defaultValue保证参数缺失时不报错,page默认第一页。totalPages用向上取整计算,(total + pageSize - 1) / pageSize是整数运算的常用技巧。返回字符串"book/list"会被视图解析器拼成/WEB-INF/jsp/book/list.jsp。

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <tr><th>书名</th><th>作者</th><th>可借</th><th>操作</th></tr> <c:forEach items="${books}" var="book"> <tr> <td>${book.title}</td> <td>${book.author}</td> <td>${book.availableCopies}</td> <td> <c:if test="${book.availableCopies > 0}"> <a href="/borrow/${book.id}">借阅</a> </c:if> <c:if test="${book.availableCopies == 0}"> <span>已借完</span> </c:if> </td> </tr> </c:forEach> </table>

逻辑说明:<c:forEach>遍历 Controller 放进 model 的books。<c:if>根据可借数量决定显示「借阅」链接还是「已借完」。注意${book.title}默认不做 HTML 转义,如果书名里含特殊字符会有 XSS 风险,生产环境应使用<c:out>或fn:escapeXml。

4.2 表单提交与后端校验

新增图书的页面是一个表单,提交到 Controller 的 POST 方法。校验分两层:前端 HTML5 的required做基础拦截,后端用注解做业务校验。

@PostMapping("/add") public String add(@Valid Book book, BindingResult result, Model model) { if (result.hasErrors()) { model.addAttribute("errors", result.getAllErrors()); return "book/add"; } bookService.save(book); return "redirect:/book/list"; }
public class Book { @NotBlank(message = "ISBN 不能为空") private String isbn; @NotBlank(message = "书名不能为空") private String title; @Min(value = 1, message = "馆藏数量至少为 1") private Integer totalCopies; // getter/setter 省略 }

逻辑说明:@Valid触发实体上的校验注解,BindingResult必须紧跟在被校验对象后面,否则校验失败会直接抛异常而不是进方法。校验不通过时返回原页面并带上错误信息,通过则重定向到列表页。重定向用redirect:前缀,避免刷新页面重复提交表单,这是 PRG 模式的基本实践。

4.3 借阅记录的展示与超期标记

借阅记录页需要展示每本书的借出时间、应还时间,并标出超期项。超期判断可以在 SQL 里做,也可以在 JSP 里做,推荐在 SQL 里算好状态。

<select id="selectByReader" resultType="com.demo.entity.BorrowRecord"> SELECT r.*, b.title, CASE WHEN r.status = 0 AND r.due_time &lt; NOW() THEN 1 ELSE 0 END AS overdue FROM borrow_record r LEFT JOIN book b ON r.book_id = b.id WHERE r.reader_id = #{readerId} ORDER BY r.borrow_time DESC </select>

逻辑说明:CASE WHEN在查询时动态计算是否超期,overdue为 1 表示已超期。用LEFT JOIN而不是INNER JOIN,保证即使图书被删除,借阅记录仍然能显示。参数说明:NOW()是数据库当前时间,注意服务器时区和数据库时区要一致,否则超期判断会偏几个小时,这是跨时区部署时的常见问题。

5. 避坑与排查:这套组合最容易翻车的五个地方

5.1 JSP 页面 404 或直接下载源码

现象:访问页面返回 404,或者浏览器直接把 JSP 文件下载下来而不是渲染。

原因:JSP 放错目录,或者缺少tomcat-embed-jasper依赖。SpringBoot 打包成 jar 时,webapp目录不会自动被识别,需要额外配置。

解决:确认 JSP 在src/main/webapp/WEB-INF/jsp/下;确认 pom 里有 jasper 依赖;如果打成 jar 后 JSP 找不到,在 pom 的spring-boot-maven-plugin里配置resources把 webapp 目录包含进去,或者改用 war 打包。

5.2 MyBatis 的 Mapper 扫描不到

现象:启动时报Invalid bound statement (not found),或者注入 Mapper 时提示找不到 Bean。

原因:Mapper 接口没有被扫描到,或者 XML 文件位置和mapper-locations配置不匹配。

解决:在启动类上加@MapperScan("com.demo.mapper");在application.properties里配mybatis.mapper-locations=classpath:mapper/*.xml;确认 XML 的namespace和接口全限定名完全一致,一个字母都不能差。

5.3 事务不生效导致库存对不上

现象:借书时库存扣了但记录没插,或者反过来,数据出现不一致。

原因:@Transactional加在了 private 方法上,或者同类内部方法直接调用绕过了代理,或者异常被 catch 后没有重新抛出。

解决:事务方法必须是 public;同类内部调用要走代理对象(注入自身或用 AopContext);catch 块里如果处理了异常,要么重新抛出,要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

5.4 分页查询的 count 和 list 条件不一致

现象:列表显示 10 条,但总页数算出来是 0 或者明显偏多。

原因:count 查询和 list 查询的 WHERE 条件写得不一样,比如 list 带了available_copies > 0而 count 没带。

解决:把查询条件抽成一个公共的 SQL 片段,用<sql>标签定义,count 和 list 都<include>它。这样改条件时只改一处,不会漏。

5.5 中文乱码

现象:页面显示的中文书名变成问号或乱码。

原因:数据库字符集不是 utf8mb4,或者连接 URL 没指定编码,或者 JSP 页面没声明编码。

解决:建库时用CHARACTER SET utf8mb4;JDBC URL 加?useUnicode=true&characterEncoding=utf8;JSP 顶部加<%@ page contentType="text/html;charset=UTF-8" %>。三处都对了才不会乱码,缺一处都可能出问题。

6. 进阶技巧:把借阅规则做成可配置,而不是写死在代码里

系统能跑起来只是第一步。真正上线后你会发现,借阅天数、最大可借数量、超期罚金这些规则经常变,如果写死在代码里,每次调整都要改代码重新部署。我一般会把它们抽到一张配置表里,启动时加载进内存,改规则只需要改数据库。

CREATE TABLE sys_config ( config_key VARCHAR(50) PRIMARY KEY, config_value VARCHAR(200) NOT NULL, remark VARCHAR(200) ); INSERT INTO sys_config VALUES ('borrow.max.days', '30', '最大借阅天数'), ('borrow.max.count', '5', '单读者最大可借数量'), ('borrow.overdue.fine', '0.5', '超期每天罚金');
@Component public class BorrowConfig { @Autowired private SysConfigMapper configMapper; private Map<String, String> cache = new ConcurrentHashMap<>(); @PostConstruct public void load() { configMapper.selectAll().forEach(c -> cache.put(c.getConfigKey(), c.getConfigValue())); } public int getMaxDays() { return Integer.parseInt(cache.getOrDefault("borrow.max.days", "30")); } public int getMaxCount() { return Integer.parseInt(cache.getOrDefault("borrow.max.count", "5")); } // 配置变更后调用,刷新缓存 public void refresh() { cache.clear(); load(); } }

逻辑说明:@PostConstruct在 Bean 初始化后加载配置进ConcurrentHashMap,读配置走内存,避免每次借书都查库。getOrDefault提供兜底值,即使配置表被清空系统也不会崩。参数说明:refresh()方法留给配置管理页面调用,改完配置手动刷新,或者用定时任务每隔几分钟刷一次。用ConcurrentHashMap而不是HashMap是因为配置可能被多线程同时读写。

验证配置是否生效,最直接的办法是写一个借书接口的测试:把borrow.max.count改成 1,然后连续借两本书,第二本应该被拒绝。这个测试能同时验证配置加载、缓存读取和业务校验三条链路。

再进一步,可以把超期罚金做成定时任务,每天凌晨扫描borrow_record表里status = 0 AND due_time < NOW()的记录,批量更新为超期状态并累加罚金。定时任务用@Scheduled(cron = "0 0 1 * * ?")挂在 Service 上,注意多实例部署时要加分布式锁,否则同一批记录会被多个实例重复处理。

我踩过最深的一个坑,是早期把借阅天数直接写在 Service 的常量里,结果业务方要改成 45 天,我改了代码、重新打包、重新部署,折腾了半小时。后来抽成配置表,同样的需求改一条 SQL 就完事。规则会变的东西,永远不要写死在代码里,这是我做这类管理系统最实在的一条习惯。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询