ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SpringBoot+MyBatis+JSP图书管理系统:登录鉴权与借阅事务全链路解析

SpringBoot+MyBatis+JSP图书管理系统:登录鉴权与借阅事务全链路解析

简介:共享一套基于Spring Boot、MyBatis与JSP技术栈构建的图书管理系统完整项目,面向Java Web初学者、高校实训与毕业设计人群,尤其适合需要快速掌握框架整合与分层开发的读者。项目覆盖图书增删改查、分类管理、借阅归还等核心业务,将Spring Boot自动配置与内嵌容器、MyBatis持久层映射与事务管理、JSP页面渲染、分页插件、安全权限控制等技术有机结合,整体遵循MVC分层架构,模块职责清晰,可作为Spring Boot整合MyBatis的实战参考与课设模板。压缩包约95.3MB,共1192个文件,包含51个Java源文件、648个编译后class字节码、173个JSP页面、148个XML配置与映射文件、65个依赖JAR包,以及属性配置、前端样式脚本与图片素材等,目录组织完整,基本覆盖源码、页面、配置与运行依赖。目前已有2208人学习下载。通过完整源码和部署配置,可以理解数据库映射、分页查询、权限校验以及应用打包运行的关键流程,对完成课程设计或深入学习Java Web开发有明显帮助。

1. 一套图书管理系统能拆出多少东西:从登录页到借阅记录的全链路

这套 springboot + mybatis + jsp 的图书管理系统,表面看只是入门级别的练手项目。真跑起来才发现,光登录就牵扯会话拦截、权限校验、验证码,借书背后还压着库存扣减、逾期标记、事务回滚几笔账。哪个环节不严谨,上线第一天就出乱子。

它的技术栈很直白:SpringBoot 做应用骨架,MyBatis 管数据,JSP 渲染页面,MySQL 做存储,典型三层架构。正因为典型,它适合刚学完 SSM 的新手,也适合被各种框架包围、想回头补地基的写码人。

登录拦截器怎么放行、动态 SQL 怎么拼条件、事务边界画在哪,这些细节不看源码猜不到。下面按先看结构、再跑起来、最后上手改的顺序过一遍。

2. 项目结构与技术选型:SpringBoot 2.x 怎么把 JSP 和 MyBatis 组合起来

2.1 为什么是这个组合而不是前后端分离

先回答一个必然被问的问题:JSP 都「过时」了,为什么还有项目这么搭?因为这套系统的应用场景是图书馆后台管理,用户是馆员,页面量不大、交互不复杂,服务端渲染反而省事。前后端分离要维护两套工程、处理跨域、联调接口,对一个几十个页面的内部系统来说成本并不划算。SpringBoot 负责提供内嵌 Tomcat 和自动配置,MyBatis 用 XML 写 SQL 比 JPA 灵活,JSP 放在 WEB-INF 下受容器保护,用户没法通过浏览器直接访问 JSP 源码,安全性天然好一点。这套系统的 SpringBoot 落在 2.x 分支也是同样的道理——3.x 之后 javax 改 jakarta,JSP 的兼容成本变高,JSP 项目的「舒适区」基本都停在 2.x。

从学习价值看,这个组合覆盖了 Java Web 一条完整链路:浏览器发请求进 Servlet 容器,经 Spring MVC 的 DispatcherServlet 分发到 Controller,再往下走 Service、Mapper,最后落到数据库。MyBatis 把结果集映射回实体,Model 把数据带到 JSP 页面渲染。链路里的每一站都有明确的代码落点,不像微服务那样一个请求要跨好几个应用节点,对想搞清楚「一个请求到底经历了什么」的人,这套系统是很好的解剖样本。

2.2 目录结构:一眼看出分层

打开工程,先看 src/main 下的三个根目录。

src/main ├── java/com/library │ ├── controller │ │ ├── LoginController.java │ │ ├── BookController.java │ │ ├── BorrowController.java │ │ └── ReaderController.java │ ├── service │ │ ├── BookService.java │ │ ├── BookServiceImpl.java │ │ ├── BorrowService.java │ │ └── BorrowServiceImpl.java │ ├── mapper │ │ ├── BookMapper.java │ │ └── BorrowMapper.java │ ├── entity │ │ ├── Book.java │ │ ├── Reader.java │ │ └── BorrowRecord.java │ ├── interceptor │ │ └── LoginInterceptor.java │ └── LibraryApplication.java ├── resources │ ├── mapper │ │ ├── BookMapper.xml │ │ └── BorrowMapper.xml │ └── application.yml └── webapp ├── WEB-INF │ ├── jsp │ │ ├── login.jsp │ │ ├── book/list.jsp │ │ ├── book/edit.jsp │ │ └── borrow/record.jsp │ └── web.xml └── static └── css/style.css

controller 只做参数接收和视图跳转,service 里放业务规则,mapper 接口和 XML 一一对应。entity 是数据库表的忠实映射,字段名跟着表走。interceptor 单独拆了一层,说明作者把登录鉴权当成横切关注点处理,没有写进每个 Controller 里。JSP 统一放在 webapp/WEB-INF/jsp 下,这个位置有两个好处:一是 WEB-INF 是 Servlet 规范里受保护的目录,浏览器直接输 URL 访问不到;二是所有页面跳转必须先经过 Controller,相当于多了一道访问控制,页面路径不会裸露在地址栏里。

2.3 pom.xml 与 application.yml 的关键配置

pom.xml 里最容易被忽略的是 JSP 相关的两个依赖。SpringBoot 默认用内嵌 Tomcat,但内嵌 Tomcat 默认没有 JSP 解析能力,缺了 tomcat-embed-jasper,启动不报错,访问 JSP 页面直接 404。JSTL 标签库也要单独引入,页面里用 c:forEach 全靠它。

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- mybatis 官方 starter,版本号需与 SpringBoot 大版本匹配,选 release 版本 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> </dependency> <!-- 内嵌 Tomcat 跑 JSP 必须要这个,否则页面 404 --> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> </dependencies>

这里只列了核心依赖。mybatis 和 mysql 驱动的版本不要照抄别人,mybatis-spring-boot-starter 的版本和 SpringBoot 有对应关系,选错了可能出现兼容问题,建议按你自己 SpringBoot 的大版本去查对应 release。

application.yml 里有三个配置点决定这套系统能不能起来:

server: port: 8080 servlet: encoding: charset: UTF-8 force: true spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mvc: view: prefix: /WEB-INF/jsp/ suffix: .jsp mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.library.entity configuration: map-underscore-to-camel-case: true

第一个是视图解析器,spring.mvc.view.prefix 和 suffix 告诉 SpringBoot 找页面时去 WEB-INF/jsp 下找、自动补 .jsp 后缀。Controller 里 return "book/list",实际渲染的是 webapp/WEB-INF/jsp/book/list.jsp。第二个是 MyBatis 的 mapper-locations,声明 XML 映射文件在 classpath:mapper 下,type-aliases-package 让 XML 里写 resultType 时可以用简写类名。第三个是下划线转驼峰映射,数据库字段 create_time 自动映射到实体的 createTime,省去手写 resultMap 的重复劳动。这三处配置任何一处写错,表现都是运行期才暴露的怪异问题,后面避坑章节会细讲。

提示:数据库先建好 library_db,SQL 脚本通常在项目根目录的 sql 文件夹下,导入时注意库名和账号密码要和 yml 里一致。

3. 核心模块拆解:登录鉴权、图书 CRUD、借还事务的完整链路

3.1 登录鉴权:拦截器放行规则是最容易翻车的点

登录功能在这套系统里不只是一张表单。它分成三层:登录页展示、登录校验逻辑、全局访问拦截。前两层在 LoginController 里,第三层在 interceptor 包下。

@Controller public class LoginController { @GetMapping("/login") public String loginPage() { return "login"; } @PostMapping("/login") public String doLogin(String username, String password, HttpSession session, Model model) { Reader reader = readerService.login(username, password); if (reader == null) { model.addAttribute("error", "账号或密码错误"); return "login"; } session.setAttribute("loginUser", reader); return "redirect:/book/list"; } }

登录成功后把 Reader 对象放进 Session,后续所有页面判断「当前有没有人登录」就看 Session 里有没有 loginUser。密码这块多数实现会在 Service 里做 MD5 加盐后比对,尽量避免明文存储。登录失败返回 login 视图,同时通过 Model 回显错误信息,JSP 页面用 EL 表达式把 error 取出来展示在表单上方。

真正拦人的是 LoginInterceptor:

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录页和静态资源,避免死循环跳转 String uri = request.getRequestURI(); if ("/login".equals(uri) || uri.startsWith("/static/")) { return true; } // 没有登录就重定向回登录页 Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }

这个拦截器的注册方式要看清,SpringBoot 里通过 WebMvcConfigurer 的 addInterceptors 方法注入,而不是在 web.xml 配 filter:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/static/**", "/error"); } }

addPathPatterns("/**") 拦所有路径,excludePathPatterns 放行登录页、静态资源和错误页。这里有两个细节值得注意:一是拦截器内部定义的放行逻辑和注册时的 excludePathPatterns 是两套并存的防线,实际生效顺序是注册排除在先、拦截器内判断在后,所以两处都要覆盖到。二是静态资源,CSS、JS、图片都放在 /static 目录,注册时忘了排除,就会出现登录后页面排版全乱的现象——HTML 正常加载了,但 CSS 全被拦截重定向回登录页,这是这套系统最常见的翻车现场。

3.2 图书 CRUD:MyBatis 动态 SQL 与分页的配合

图书管理是核心数据模块,BookController 暴露了列表、新增、编辑、删除、条件查询五个入口。这里最有看头的是 Mapper 层,查询条件不是写死的,允许按书名、作者、分类三个维度任意组合,这就是 MyBatis 动态 SQL 的用武之地。

<select id="selectByCondition" resultType="Book"> SELECT * FROM book <where> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select> <select id="countByCondition" resultType="int"> SELECT COUNT(*) FROM book <where> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> </select>

where 标签会在没有任何条件成立时自动去掉 WHERE 关键字,也会在条件成立时自动去掉第一个 AND,这是 MyBatis 动态 SQL 里最常用的技巧。keyword 参数做 LIKE 模糊匹配时,建议用 CONCAT 拼接百分号,而不是直接写 '%'#{keyword}'%',后一种写法在部分数据库方言下会报错。分页是手工 LIMIT offset, pageSize 实现,offset 在 Service 层计算:(pageNum - 1) * pageSize,pageSize 通常固定 10。数据量大时这个方案有深翻页偏移问题,但图书管理系统数据量撑死几万条,完全够用。

配套的 count 查询和 list 查询必须成对出现,Service 层要把两者组装成一个分页对象传给页面:

public PageInfo<Book> queryPage(int pageNum, int pageSize, String keyword, Integer categoryId) { int offset = (pageNum - 1) * pageSize; List<Book> list = bookMapper.selectByCondition(keyword, categoryId, offset, pageSize); int total = bookMapper.countByCondition(keyword, categoryId); // 组装分页对象:当前页数据、总条数、当前页码、总页数 PageInfo<Book> pageInfo = new PageInfo<>(); pageInfo.setList(list); pageInfo.setTotal(total); pageInfo.setPageNum(pageNum); pageInfo.setPageSize(pageSize); pageInfo.setPages((total + pageSize - 1) / pageSize); return pageInfo; }

PageInfo 是自定义的封装类,pageNum、pageSize、total、pages、list 五个字段足够 JSP 分页条用。这里也可以接 PageHelper 分页插件,写法上 PageHelper.startPage(pageNum, pageSize) 之后紧跟查询,插件自动改写 SQL 加 LIMIT,还能通过 PageInfo 拿到总数。我的习惯是这种小系统不引额外依赖,手写分页更容易控制,也方便理解原理。注意 LIMIT 的两个参数含义是 offset 和 rowCount,不要把 pageNum 直接传进去,否则第一页是对的,第二页往后就错位了。

3.3 借阅与归还:事务边界画在 Service 方法上

借书不是一个 UPDATE 就能解决的。它要同时做两件事:扣减图书库存、插入借阅记录。如果只成功一半,要么库存少了记录没写,要么记录写了库存没扣,两种结果都是数据不一致,所以必须画事务。

@Service public class BorrowServiceImpl implements BorrowService { @Autowired private BookMapper bookMapper; @Autowired private BorrowMapper borrowMapper; @Transactional(rollbackFor = Exception.class) public void borrowBook(Integer bookId, Integer readerId) { // 带条件扣库存:stock > 0 才允许扣,返回受影响行数 int affected = bookMapper.decreaseStock(bookId); if (affected == 0) { throw new RuntimeException("库存不足,借阅失败"); } // 写入借阅记录,状态 0 表示在借 BorrowRecord record = new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowDate(new Date()); record.setStatus(0); borrowMapper.insert(record); } }

扣库存的 SQL 是关键,用的是带条件的 UPDATE:

<update id="decreaseStock"> UPDATE book SET stock = stock - 1 WHERE id = #{bookId} AND stock > 0 </update>

stock > 0 这个条件让库存为 0 时 UPDATE 影响行数为 0,业务层拿这个返回值判断库存是否充足,避免了「先 SELECT 再 UPDATE」之间的并发窗口。两个步骤放在同一个 @Transactional 方法里,中途抛异常时两件事一起回滚。

归还流程相反,把 stock 加回去,同时把借阅记录状态改为已还。这里还有一个逾期判断:借书时定了应还日期,还书时比较当前日期和应还日期,超了就更新逾期标记,后续报告和统计会用到。归还时同样先判断记录是否存在、状态是不是在借,防止重复归还把库存加两次。

这个模块的代码量不大,但它把两个工程习惯展示得很清楚:一是用影响行数做并发控制而不是先查后改,二是事务只包业务方法不包 Controller。事务注解加在 Controller 里也能生效,但 Controller 职责是接收参数和跳转页面,把事务放在 Service 层,将来这个逻辑被别的接口复用时不会漏掉事务。

4. 页面渲染与交互:JSP + JSTL 的列表、分页与表单回显

4.1 JSP 页面怎么把数据渲染出来

JSP 在这套系统里承担视图层,页面里不允许写 Java 代码片段,至少主流程不能写,那属于「能跑但迟早被骂」的写法。数据渲染靠两样:EL 表达式取数据,JSTL 标签控制循环和判断。

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <meta charset="UTF-8"> <title>图书列表</title> <link rel="stylesheet" href="/static/css/style.css"> </head> <body> <h2>图书列表</h2> <table border="1" cellpadding="6"> <tr> <th>书名</th> <th>作者</th> <th>库存</th> <th>操作</th> </tr> <c:forEach items="${page.list}" var="book"> <tr> <td>${book.title}</td> <td>${book.author}</td> <td>${book.stock}</td> <td> <a href="/book/edit?id=${book.id}">编辑</a> <a href="/book/delete?id=${book.id}" onclick="return confirm('确认删除这本书吗?')">删除</a> </td> </tr> </c:forEach> </table> </body> </html>

items="${page.list}" 里的 page 是 Controller 往 Model 里放的分页对象,EL 表达式自动调用 PageInfo 的 getter 逐层取属性。c:forEach 每循环一次生成一行表格,var="book" 定义循环变量名,循环体里 ${book.title} 等价于调用 book.getTitle()。这段代码体现了 JSP 的典型工作方式:数据准备在 Controller,展示逻辑在页面,页面只负责读,不负责算。

4.2 表单提交与对象绑定

新增和编辑图书共用 edit.jsp,Controller 里通过判断 book.id 是否为空区分两种场景。用户提交表单后,Spring MVC 会把表单字段自动绑定到 Book 实体,前提是表单控件的 name 属性和实体字段名一致。

<form action="/book/save" method="post"> <input type="hidden" name="id" value="${book.id}"> <label>书名</label> <input type="text" name="title" value="${book.title}"> <label>作者</label> <input type="text" name="author" value="${book.author}"> <label>ISBN</label> <input type="text" name="isbn" value="${book.isbn}"> <label>库存</label> <input type="number" name="stock" value="${book.stock}"> <button type="submit">保存</button> </form>
@PostMapping("/book/save") public String save(Book book) { if (book.getId() == null) { bookMapper.insert(book); } else { bookMapper.updateById(book); } return "redirect:/book/list"; }

隐藏域 id 保存当前记录 ID,编辑时由 Controller 往 Model 塞进 Book 对象,页面渲染时 value="${book.id}" 自动回显。当校验失败或保存报错需要回到编辑页时,Model 里的 book 对象同样能把这些值带回去,这就是「表单回显」的实现机制。注意 save 方法完成后是 redirect 而不是 forward——直接返回 book/list 视图会导致刷新页面时重复提交表单,重定向正好规避这个问题,这是 POST 后跳转的标准姿势。

4.3 分页条:一个可复用的 JSP 片段

列表页底部要有分页条,一般会把分页条抽成公共 JSP 片段用 include 引入,避免每个列表页复制一份页码逻辑。分页条要把当前页码高亮、把总页数算对、把每页的链接参数拼准。

<c:if test="${page.total > 0}"> <div class="pagination"> <c:if test="${page.pageNum > 1}"> <a href="/book/list?pageNum=${page.pageNum - 1}&keyword=${keyword}">上一页</a> </c:if> <c:forEach begin="1" end="${page.pages}" var="p"> <c:choose> <c:when test="${p == page.pageNum}"> <span class="current">${p}</span> </c:when> <c:otherwise> <a href="/book/list?pageNum=${p}&keyword=${keyword}">${p}</a> </c:otherwise> </c:choose> </c:forEach> <c:if test="${page.pageNum < page.pages}"> <a href="/book/list?pageNum=${page.pageNum + 1}&keyword=${keyword}">下一页</a> </c:if> </div> </c:if>

这里有一个很容易踩的坑:分页链接里除了 pageNum 还要带上 keyword 和 categoryId。很多人在列表页筛选完关键词后点第二页,结果数据变成全部图书的第一页,就是因为翻页链接丢掉了查询条件。Controller 侧也要保持接收这些参数并把它们放回 Model,页面才能继续拼进链接。分页条放在 WEB-INF/jsp/common/pagination.jsp,各页面用 jsp:include 引入,传不同的 listUrl 前缀即可复用。

提示:分页条里的 c:forEach begin 和 end 能正常使用靠的是 JSTL 核心库,如果页面报错提示找不到 c 标签,先检查 taglib 声明和 jstl 依赖,这两个经常被一起漏掉。

5. 避坑手册:本地跑通这套系统的一线记录

这一章写的是本地复现和改造这套系统时实际撞过的坑,每一条都是现象、原因、解决三段式的血泪经验。新手照着跑如果卡住,先来对号入座。

5.1 启动成功但页面 404

现象:SpringBoot 启动日志一切正常,控制台打印了 Tomcat started,但浏览器访问 http://localhost:8080/book/list 返回 404,找不到页面。

原因:内嵌 Tomcat 本身不包含 JSP 解析器。spring-boot-starter-web 只打包了 servlet-api 和基础容器,要跑 JSP 必须额外引入 tomcat-embed-jasper。缺失时 JSP 文件不会被编译,请求路径自然 404,而且是那种不报任何堆栈的「安静 404」,特别容易让人怀疑是路径写错。

解决:在 pom.xml 补上 tomcat-embed-jasper 依赖,同时检查 jstl 依赖是否齐全。改完删掉 target 目录重新打包启动一次,避免旧编译产物残留。如果还 404,再看 spring.mvc.view.prefix 和 suffix 是否配置正确,Controller 返回 book/list 时物理路径应该指向 webapp/WEB-INF/jsp/book/list.jsp。

5.2 登录后页面排版全乱,CSS 加载不出来

现象:能登录能跳转,但所有页面的样式都没了,Network 面板里 css 文件请求返回的是登录页的 HTML,而不是 CSS 内容。

原因:登录拦截器把静态资源也拦住了。addPathPatterns("/**") 拦截所有路径,CSS 请求进到 preHandle 时发现 Session 里有用户但请求路径不匹配 /login 也不匹配 /static/ 前缀,于是被重定向到登录页。

解决:注册拦截器时通过 excludePathPatterns 把 /static/** 排除,同时在拦截器的 preHandle 里加一层「以 /static/ 开头直接放行」的兜底判断。两处都要写,这是防御性习惯:将来配置中心的管理员改了 exclude 列表,拦截器内部逻辑仍然能兜住。

5.3 MySQL 8 驱动类和时区报错

现象:启动时报 SQLNonTransientConnectionException 或 Communications link failure,但 MySQL 服务本身是好的,用命令行能正常连接。

原因:MySQL 5.x 和 8.x 的驱动类全名不一样,老资料里写的 com.mysql.jdbc.Driver 在 MySQL 8 下已不推荐使用;同时新版驱动连接的 URL 里必须带 serverTimezone 参数,否则 JDBC 驱动无法确定时区就抛异常。

解决:driver-class-name 写成 com.mysql.cj.jdbc.Driver,URL 里加 serverTimezone=Asia/Shanghai。如果遇到 Public Key Retrieval is not allowed 的报错,多半是 MySQL 8 的 caching_sha2_password 认证插件导致,在 URL 上追加 allowPublicKeyRetrieval=true 即可。注意这些参数只影响 Java 程序连接,Navicat 类客户端有自己的配置,不要拿两个环境互相验证。

5.4 页面中文全是乱码

现象:登录页输入中文用户名,保存到数据库后变成问号;从数据库查出来显示也是乱码。

原因:乱码这种问题在老项目里经常被当成玄学,其实背后就是三处编码不一致。JSP 页面本身的 pageEncoding 没写 UTF-8、表单 post 提交时的请求体编码不是 UTF-8、JDBC 连接串没带 characterEncoding=utf8,三处只要有一处不统一,中文数据就开始变形。

解决:JSP 头部写死 pageEncoding="UTF-8",contentType 的 charset 也写 UTF-8;application.yml 里配 server.servlet.encoding.force=true,强制请求和响应都走 UTF-8;JDBC URL 带 useUnicode=true&characterEncoding=utf8。改完重启后,把旧库里已经乱码的脏数据删掉重新插一遍验证,不要在同一个连接里用新配置读旧数据,否则会误判配置没生效。

5.5 用了 @Transactional 但数据没回滚

现象:借书时故意让库存超卖触发异常,结果库存没变,但借阅记录已经插入,事务没有回滚。

原因:事务注解默认只对 RuntimeException 和 Error 回滚,对受检异常不回滚;另一个经典原因是同一个类内部方法自调用——某个方法内部直接调用同类里的另一个 @Transactional 方法,Spring 的 AOP 代理没有介入,注解直接失效。

解决:一是 @Transactional 上显式写 rollbackFor = Exception.class,把受检异常也纳入回滚范围;二是把事务方法拆到不同的类里,通过注入的代理对象调用,避免自调用。排查时可以看日志里有没有事务拦截器的记录,通常事务切面失效时日志里完全没有事务相关输出,这一条能快速定位是不是自调用问题。

6. 二次开发思路:加读者分级和批量导入,把骨架长成完整系统

这套系统的骨架已经具备管理系统需要的全部基础设施,接下来就是往骨架上加器官。这里给两个具体的改造方向,一个是加业务规则,一个是加操作效率。

读者分级是图书管理系统最常见的需求扩展。现在读者表只有基本信息,没有等级概念,借阅上限所有人都一样。改造思路是给 Reader 实体加一个 level 字段(1 普通、2 教师、3 管理员),再按等级设置 maxBorrowCount。借书时在 BorrowService 里多一步校验:先查当前读者在借数量,达到上限就抛业务异常。

// 借书前校验读者当前在借数量是否达到上限 int borrowingCount = borrowMapper.countByReaderAndStatus(readerId, 0); if (borrowingCount >= reader.getMaxBorrowCount()) { throw new RuntimeException("该读者已达到最大借阅数量"); }

这个改动只碰三处:数据库表加两个字段、读者编辑页面加两个表单项、借书 Service 加一次查询校验。事务不变,拦截器不用动,页面结构也不变,改动成本很小,但业务价值很直观。

批量导入适合馆藏量大的场景。手工一本一本录书不现实,常见做法是提供「上传 Excel 批量导入」:前端用表单提交文件,Service 里用 POI 或 EasyExcel 解析每一行,组装成 Book 列表后交给 MyBatis 批量插入。

<insert id="batchInsertBooks"> INSERT INTO book (title, author, isbn, category_id, stock, create_time) VALUES <foreach collection="bookList" item="book" separator=","> (#{book.title}, #{book.author}, #{book.isbn}, #{book.categoryId}, #{book.stock}, NOW()) </foreach> </insert>

批量插入比一条一条 insert 快一个数量级,数据量大时尤其明显。导入完成后要返回结果报告,告诉用户成功多少条、失败多少行、失败原因是什么,别让用户面对一个空白页面猜结局。

改造完的验证方法我习惯分三步走:第一步用手写 SQL 或表结构对比工具确认字段和索引正确;第二步写一个 Spring Boot 测试,把借阅上限、批量导入的核心方法直接调一遍,数据用独立测试库,不碰真实数据;第三步才启动应用用浏览器走一遍完整流程。核心业务逻辑先让自动化测试兜住,比每次手动点界面高效得多。

做完这套二次开发我才真正理解了这类「老技术栈」系统的价值:JSP 页面改起来直接,MyBatis 的 SQL 透明可控,SpringBoot 把环境复杂度压到最低,业务逻辑看得见摸得着。从那以后我每次接手这种单体系统,都强制自己先理清事务边界和拦截器放行规则,再动手改功能。这个顺序帮我省掉了后来无数的返工,希望帮到你。

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

RELATED READING

延伸阅读

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