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 改 jakartaJSP 的兼容成本变高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.csscontroller 只做参数接收和视图跳转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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- mybatis 官方 starter版本号需与 SpringBoot 大版本匹配选 release 版本 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependency !-- 内嵌 Tomcat 跑 JSP 必须要这个否则页面 404 -- dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-jasper/artifactId /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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_dbSQL 脚本通常在项目根目录的 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 取出来展示在表单上方。真正拦人的是 LoginInterceptorpublic 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 配 filterConfiguration 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 图书 CRUDMyBatis 动态 SQL 与分页的配合图书管理是核心数据模块BookController 暴露了列表、新增、编辑、删除、条件查询五个入口。这里最有看头的是 Mapper 层查询条件不是写死的允许按书名、作者、分类三个维度任意组合这就是 MyBatis 动态 SQL 的用武之地。select idselectByCondition resultTypeBook SELECT * FROM book where if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR author LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND category_id #{categoryId} /if /where ORDER BY id DESC LIMIT #{offset}, #{pageSize} /select select idcountByCondition resultTypeint SELECT COUNT(*) FROM book where if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR author LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND category_id #{categoryId} /if /where /selectwhere 标签会在没有任何条件成立时自动去掉 WHERE 关键字,也会在条件成立时自动去掉第一个 AND这是 MyBatis 动态 SQL 里最常用的技巧。keyword 参数做 LIKE 模糊匹配时建议用 CONCAT 拼接百分号而不是直接写 %#{keyword}%后一种写法在部分数据库方言下会报错。分页是手工 LIMIT offset, pageSize 实现offset 在 Service 层计算(pageNum - 1) * pageSizepageSize 通常固定 10。数据量大时这个方案有深翻页偏移问题但图书管理系统数据量撑死几万条完全够用。配套的 count 查询和 list 查询必须成对出现Service 层要把两者组装成一个分页对象传给页面public PageInfoBook queryPage(int pageNum, int pageSize, String keyword, Integer categoryId) { int offset (pageNum - 1) * pageSize; ListBook list bookMapper.selectByCondition(keyword, categoryId, offset, pageSize); int total bookMapper.countByCondition(keyword, categoryId); // 组装分页对象当前页数据、总条数、当前页码、总页数 PageInfoBook 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 是关键用的是带条件的 UPDATEupdate iddecreaseStock UPDATE book SET stock stock - 1 WHERE id #{bookId} AND stock 0 /updatestock 0 这个条件让库存为 0 时 UPDATE 影响行数为 0业务层拿这个返回值判断库存是否充足避免了「先 SELECT 再 UPDATE」之间的并发窗口。两个步骤放在同一个 Transactional 方法里中途抛异常时两件事一起回滚。归还流程相反把 stock 加回去同时把借阅记录状态改为已还。这里还有一个逾期判断借书时定了应还日期还书时比较当前日期和应还日期超了就更新逾期标记后续报告和统计会用到。归还时同样先判断记录是否存在、状态是不是在借防止重复归还把库存加两次。这个模块的代码量不大但它把两个工程习惯展示得很清楚一是用影响行数做并发控制而不是先查后改二是事务只包业务方法不包 Controller。事务注解加在 Controller 里也能生效但 Controller 职责是接收参数和跳转页面把事务放在 Service 层将来这个逻辑被别的接口复用时不会漏掉事务。4. 页面渲染与交互JSP JSTL 的列表、分页与表单回显4.1 JSP 页面怎么把数据渲染出来JSP 在这套系统里承担视图层页面里不允许写 Java 代码片段至少主流程不能写那属于「能跑但迟早被骂」的写法。数据渲染靠两样EL 表达式取数据JSTL 标签控制循环和判断。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html head meta charsetUTF-8 title图书列表/title link relstylesheet href/static/css/style.css /head body h2图书列表/h2 table border1 cellpadding6 tr th书名/th th作者/th th库存/th th操作/th /tr c:forEach items${page.list} varbook 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} onclickreturn confirm(确认删除这本书吗)删除/a /td /tr /c:forEach /table /body /htmlitems${page.list} 里的 page 是 Controller 往 Model 里放的分页对象EL 表达式自动调用 PageInfo 的 getter 逐层取属性。c:forEach 每循环一次生成一行表格varbook 定义循环变量名循环体里 ${book.title} 等价于调用 book.getTitle()。这段代码体现了 JSP 的典型工作方式数据准备在 Controller展示逻辑在页面页面只负责读不负责算。4.2 表单提交与对象绑定新增和编辑图书共用 edit.jspController 里通过判断 book.id 是否为空区分两种场景。用户提交表单后Spring MVC 会把表单字段自动绑定到 Book 实体前提是表单控件的 name 属性和实体字段名一致。form action/book/save methodpost input typehidden nameid value${book.id} label书名/label input typetext nametitle value${book.title} label作者/label input typetext nameauthor value${book.author} labelISBN/label input typetext nameisbn value${book.isbn} label库存/label input typenumber namestock value${book.stock} button typesubmit保存/button /formPostMapping(/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 classpagination c:if test${page.pageNum 1} a href/book/list?pageNum${page.pageNum - 1}keyword${keyword}上一页/a /c:if c:forEach begin1 end${page.pages} varp c:choose c:when test${p page.pageNum} span classcurrent${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.DriverURL 里加 serverTimezoneAsia/Shanghai。如果遇到 Public Key Retrieval is not allowed 的报错多半是 MySQL 8 的 caching_sha2_password 认证插件导致在 URL 上追加 allowPublicKeyRetrievaltrue 即可。注意这些参数只影响 Java 程序连接Navicat 类客户端有自己的配置不要拿两个环境互相验证。5.4 页面中文全是乱码现象登录页输入中文用户名保存到数据库后变成问号从数据库查出来显示也是乱码。原因乱码这种问题在老项目里经常被当成玄学其实背后就是三处编码不一致。JSP 页面本身的 pageEncoding 没写 UTF-8、表单 post 提交时的请求体编码不是 UTF-8、JDBC 连接串没带 characterEncodingutf8三处只要有一处不统一中文数据就开始变形。解决JSP 头部写死 pageEncodingUTF-8contentType 的 charset 也写 UTF-8application.yml 里配 server.servlet.encoding.forcetrue强制请求和响应都走 UTF-8JDBC URL 带 useUnicodetruecharacterEncodingutf8。改完重启后把旧库里已经乱码的脏数据删掉重新插一遍验证不要在同一个连接里用新配置读旧数据否则会误判配置没生效。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 idbatchInsertBooks INSERT INTO book (title, author, isbn, category_id, stock, create_time) VALUES foreach collectionbookList itembook separator, (#{book.title}, #{book.author}, #{book.isbn}, #{book.categoryId}, #{book.stock}, NOW()) /foreach /insert批量插入比一条一条 insert 快一个数量级数据量大时尤其明显。导入完成后要返回结果报告告诉用户成功多少条、失败多少行、失败原因是什么别让用户面对一个空白页面猜结局。改造完的验证方法我习惯分三步走第一步用手写 SQL 或表结构对比工具确认字段和索引正确第二步写一个 Spring Boot 测试把借阅上限、批量导入的核心方法直接调一遍数据用独立测试库不碰真实数据第三步才启动应用用浏览器走一遍完整流程。核心业务逻辑先让自动化测试兜住比每次手动点界面高效得多。做完这套二次开发我才真正理解了这类「老技术栈」系统的价值JSP 页面改起来直接MyBatis 的 SQL 透明可控SpringBoot 把环境复杂度压到最低业务逻辑看得见摸得着。从那以后我每次接手这种单体系统都强制自己先理清事务边界和拦截器放行规则再动手改功能。这个顺序帮我省掉了后来无数的返工希望帮到你。本文还有配套的精品资源点击获取