Spring Boot + MyBatis 图书管理系统:从课程设计到面试项目实战

Spring Boot + MyBatis 图书管理系统:从课程设计到面试项目实战 简介一份基于Spring Boot与MyBatis的图书管理系统课程设计源码面向需要完成Java Web课程设计或毕业设计的高校学生也可供初学者学习主流框架整合。系统涵盖管理员与读者两种角色管理员可对图书信息进行增删改查查看读者与借阅记录读者能够查看、查询图书修改个人资料并查看自己的借阅记录同时包含登录、注销、修改密码等完整认证模块。资源包共73个文件压缩包大小15.23MB23个Java源文件覆盖控制器、服务、Mapper及实体层15个HTML页面配合5个JS与6个CSS完成前端交互另外提供SQL数据库脚本、Maven配置及application.yml等环境配置便于导入常见IDE后直接运行。配套的SQL脚本展示了规范的表结构设计有助于学习数据库范式与关联查询项目目录层次清晰可快速定位各层代码。已有645人浏览学习适合作为课程设计参考也可帮助理解Spring BootMyBatis前后端联动与项目分层结构。1. Spring Boot MyBatis 图书管理系统从课程设计到可扩展的 Web 项目如果你正在做基于 Java 的图书管理系统课程设计或者手里需要一个 Spring Boot Web 项目用来应付面试中的项目提问这套源码比大部分网上流传的 Servlet JSP 老代码更适合。它把管理员、读者、借阅记录三条业务线完整串起来登录注销、修改密码、图书增删改查、借阅记录查询都做了技术栈也不是落后的 JSP 页面而是 Spring Boot MyBatis 前后端一体结构。压缩包里除了源码还有 library.sql 数据库脚本和 Maven 工程拿到手先建库再改 application.yml 里的数据库用户名密码就能启动。对于刚接触 Spring Boot 的人来说重点在「怎么跑起来」对已经有几年经验的人来说更值得看的是它的会话管理、MyBatis 动态 SQL 写法以及数据库脚本的组织方式这些都能拿到面试里讲。2. 登录认证与注销从 Session 拦截器到 MyBatis 查询实现2.1 认证方案为什么要选 Session 拦截器而不是 Spring Security登录模块是图书管理系统的入口也是答辩时最容易被抓着问的部分。这套项目没有引入 Spring Security 全家桶而是用传统的 Session 拦截器方案实现认证。这个选择在课程设计场景下是合理的Spring Security 的过滤器链、CSRF 防护和默认登录页对新手太黑盒演示时一旦配置出错整个系统都进不去。Session 方案的流程很直白登录页提交用户名和密码UserMapper 按用户名查询用户记录Service 比对密码通过后把用户信息放进 HttpSession之后的每次请求都由拦截器检查 Session 里有没有用户。这里有一个容易被忽略的设计点查询用户时不要按照用户名和密码两个条件去查更不要在 SQL 里拼接明文密码。正确做法是先按用户名查出用户对象再到 Service 层做密码比对。这样有两个好处一是密码校验逻辑可以复用比如后面做修改密码时还能用同一个方法二是避免密码出现在 SQL 日志里。MyBatis 的 Mapper 接口和 XML 一般是这样配合的public interface UserMapper { User selectByUsername(Param(username) String username); }select idselectByUsername resultTypecom.example.bookmanager.entity.User select id, username, password, role, nickname from user where username #{username} /select这段逻辑里#{username}是 MyBatis 预编译参数最终会变成 JDBC 的?占位符不会产生字符串拼接注入问题。Param(username)用于给参数命名虽然这里只有一个参数但保留注解能让 XML 里的参数名和接口方法形成显式映射避免编译时参数名丢失导致绑定失败。2.2 密码加密与修改密码的落地细节课程设计里最常见的密码存储错误是直接存明文稍微好一点的是用 MD5但 MD5 已经被彩虹表覆盖得差不多了。如果不想把整个 Spring Security 引进来可以单独引入 spring-security-crypto 依赖只使用它的 BCryptPasswordEncoder 做加密和比对dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency使用方式很简单注册用户时encoder.encode(rawPassword)登录时encoder.matches(rawPassword, encodedPassword)。BCrypt 每次生成的密文不同但matches可以正确校验这是因为密文里包含了盐值信息不需要额外建 salt 字段。如果你不愿意加这个依赖手动加盐也可以user 表加一个 salt 字段存储随机字符串密码存MD5(salt password)比对时先查 salt 再加密比对。两种方案对比下来BCrypt 更省事也更好在答辩里解释。修改密码功能要处理的坑比登录多。前端需要传旧密码、新密码和确认密码后端先校验两个新密码是否一致再拿当前登录用户的旧密码去比对。比对通过后执行 update而这条 update 必须带用户 id 条件。很多人在课程设计里忽略这一点直接被 MyBatis 的 update 语句全表覆盖属于事故级 bug。正确写法是update idupdatePassword update user set password #{newPassword} where id #{id} /update注意 MyBatis 的 update 标签里如果没有where执行时会把整张表的 password 全部改掉而且不会报错只有影响行数异常大才会暴露问题。这也是为什么建议在 Service 层检查updatePassword的返回值影响行数为 0 时直接抛出异常。2.3 登录拦截器和注销清理Session 方案的核心是拦截器。没有拦截器的话任何人都能在浏览器地址栏输入/book/list绕过登录页这个漏洞在答辩时被指出来会很尴尬。自定义拦截器相当简单public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object loginUser request.getSession().getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login); return false; } return true; } }注册拦截器时要注意放行路径。登录请求本身、静态资源和错误页面都必须排除否则会形成死循环跳转。Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /static/**, /error); }addPathPatterns(/**)表示拦截所有请求excludePathPatterns按顺序匹配放行。这里特别容易漏掉/static/**漏掉之后页面能打开但 css、js 全部丢失控制台报 404。注销功能就简单很多调用session.invalidate()让 Session 失效然后重定向到登录页。不要把用户信息一条条 remove直接失效整个 Session 更干净。登录认证相关接口可以整理成下面这张表方便对照代码补齐功能请求路径方法权限要求显示登录页/loginGET匿名登录提交/loginPOST匿名注销/logoutGET登录用户修改密码/user/passwordPOST登录用户3. 图书管理模块MyBatis 动态 SQL、分页与借阅记录关联查询3.1 管理员端图书增删改查的接口设计图书管理是整个系统业务量最大的模块管理员需要对图书进行增删改查读者只能查看图书。先看 book 表的结构设计这张表直接决定后面查询写起来顺不顺手字段名类型说明idint(11)主键自增book_namevarchar(100)书名authorvarchar(50)作者publishervarchar(100)出版社isbnvarchar(20)ISBN 编号statustinyint(4)0-在馆1-已借出create_timedatetime入库时间status字段是借阅状态的核心管理员在图书列表页面应该能直接看到每一本书是「可借」还是「已借出」。图书删除需要想清楚一个问题物理删除还是逻辑删除。如果借阅记录表里存了 book_id物理删除后借阅记录就成了悬空引用查询记录时 join 不到书名。我的习惯是给 book 表加一个deleted字段做逻辑删除查询时统一加deleted 0条件。课程设计里如果时间紧物理删除也能接受但要在借阅记录关联查询时用 left join 并做空值兜底。3.2 动态 SQL 实现多条件筛选管理员的图书列表通常不是无条件全查而是要支持按书名、作者、ISBN 筛选。这种场景最能体现 MyBatis 动态 SQL 的价值。一个常见的多条件查询 XML 是这样写的select idselectBookList resultTypecom.example.bookmanager.entity.Book select id, book_name, author, publisher, isbn, status, create_time from book where if testbookName ! null and bookName ! and book_name like concat(%, #{bookName}, %) /if if testauthor ! null and author ! and author like concat(%, #{author}, %) /if if testisbn ! null and isbn ! and isbn #{isbn} /if if teststatus ! null and status #{status} /if /where order by create_time desc /select这段 SQL 有两个细节值得强调。第一where标签会自动去掉第一个条件前面多余的 and 或 or避免出现where and book_name like ...这种语法错误。第二模糊查询里的like concat(%, #{bookName}, %)这里的三个参数都走预编译不会注入如果图省事写成like %${bookName}%就属于典型的${}注入风险面试官看到会直接扣分。status判断用if teststatus ! null注意不要写成teststatus 1这在 OGNL 里可能把数字字面量解析成字符导致条件失效稳妥写法是让实体类的 status 是 Integer 类型再比较就用status 1就没问题。3.3 分页查询PageHelper 还是手写 LIMIT图书列表数据量一旦超过几十条就必须分页。这里有两个路线引入 PageHelper 插件或者自己写 LIMIT。PageHelper 的使用非常省事在 pom.xml 加依赖然后在 Service 层调用PageHelper.startPage(pageNum, pageSize); ListBook bookList bookMapper.selectBookList(query); PageInfoBook pageInfo new PageInfo(bookList);PageHelper.startPage会在当前线程上下文中设置分页参数紧接着执行的第一条 MyBatis 查询会被自动拦截拼上 limit 并执行 count 查询。PageInfo里封装了总条数、总页数、当前页、是否首页尾页等数据前端拿到pageInfo.list渲染表格再渲染分页条。要注意的是startPage只对紧跟其后的一条 SQL 生效如果代码里在它和查询之间插入了别的 SQL分页就会串数据。这个问题在项目里出现频率很高排查时先看调用顺序。如果想在答辩里展示自己对 SQL 的理解我更推荐手写分页。Mapper 里写两个方法一个查 count 一个查列表select idcountBookList resultTypelong select count(*) from book where if testbookName ! null and bookName ! and book_name like concat(%, #{bookName}, %) /if /where /selectselect idselectBookPage resultTypecom.example.bookmanager.entity.Book select id, book_name, author, publisher, isbn, status, create_time from book where if testbookName ! null and bookName ! and book_name like concat(%, #{bookName}, %) /if /where order by create_time desc limit #{offset}, #{pageSize} /selectlimit的两个参数分别是偏移量和每页条数偏移量由 Service 计算offset (pageNum - 1) * pageSize。手写分页要自己同步维护 count 查询的过滤条件条件和列表保持一致不然总条数会和列表对不上。两种方式都能跑课程设计用 PageHelper 省时间找工作阶段建议能手写推导一遍。3.4 借阅记录与图书状态的事务控制读者借书不是一个孤立的 insert。正确的业务闭环是检查图书状态 - 插入借阅记录 - 把图书 status 改为已借出。这三步必须在一个事务里否则可能出现记录插进去了但图书状态没变或者状态变了但没有借阅记录。Spring 里用Transactional来保证Transactional public void borrowBook(Integer bookId, Integer userId) { Book book bookMapper.selectById(bookId); if (book null || book.getStatus() 1) { throw new ServiceException(图书不存在或已被借出); } borrowRecordMapper.insert(bookId, userId, new Date()); int rows bookMapper.updateStatusById(bookId, 1); if (rows 0) { throw new ServiceException(图书状态更新失败请重试); } }这里的关键是updateStatusById返回受影响行数。为什么 update 还会影响 0 行呢因为 status 如果已经是 1MySQL 默认情况下影响行数是 0正好利用这个特性避免两个人同时借同一本书。Transactional是 Spring 声明式事务的核心方法内任何异常都会触发回滚把 insert 和 update 全部撤销。注意它默认只回滚 RuntimeException自己抛出的 ServiceException 需要继承 RuntimeException把它写成 RuntimeException 的子类就行。借阅记录查询要展示书名和用户名单查 borrow_record 表不够需要把 book 和 user 连起来select idselectBorrowRecordList resultTypecom.example.bookmanager.vo.BorrowRecordVO select br.id, b.book_name, u.username, br.borrow_time, br.return_time from borrow_record br left join book b on br.book_id b.id left join user u on br.user_id u.id where if testusername ! null and username ! and u.username like concat(%, #{username}, %) /if /where order by br.borrow_time desc /select用 left join 而不是 inner join 是有讲究的借阅记录一定存在但书或用户可能被删除left join 保证记录始终能查出来book_name 或 username 为空时在页面上显示「已下架」或「已注销」占位。查询结果不要直接用resultTypemap虽然省事但 key 是数据库列名转 JSON 下划线结构不统一建议定义 VO 类让查询结果直接映射成可序列化的 Java 对象。4. 数据库初始化与双模式运行library.sql、application.yml 与 Maven 打包的完整链路4.1 从零导入 library.sql拿到压缩包后第一步不要急着打开 IDE先把数据库建起来。根据项目说明需要用一个 MySQL 图形化管理工具Navicat 或者 MySQL Workbench 都可以。新建数据库名字必须叫 library字符集选择 utf8mb4然后右键数据库运行项目里的 library.sql 脚本。如果习惯命令行也可以直接用 mysql 客户端导入mysql -uroot -p -e CREATE DATABASE library DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; mysql -uroot -p library library.sql第一行命令创建数据库第二行把 SQL 脚本导入到 library 库中。utf8mb4编码能完整支持中文、emoji 和生僻字比utf8更省心。library.sql脚本内容一般是建表语句加初始数据如果包含外键约束表之间的依赖顺序就必须正确否则导入会报错。导入失败时优先看脚本里是否有DELIMITER相关的存储过程以及外键表是否存在这些问题在图形工具里都能看到具体的报错信息。4.2 application.yml 的配置项与连接参数运行文档明确要求改src/main/resources/application.yml里的 MySQL 用户名和密码这是整个项目启动的第一道关卡。配置长这样server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/library?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.bookmanager.entitydriver-class-name用的是com.mysql.cj.jdbc.Driver这是 MySQL 8 的驱动类如果是 MySQL 5.7 及以下版本驱动类是com.mysql.jdbc.Driver。连接串里的serverTimezoneAsia/Shanghai用来解决 MySQL 8 的时区报错不加会提示 The server time zone value Öйú±ê׼ʱ¼ä is unrecognized 之类的乱码错误。useSSLfalse关闭 SSL本地开发不需要加密连接。MyBatis 的mapper-locations指定 XML 文件位置type-aliases-package让 MyBatis 自动识别实体包XML 里写 resultType 时不用再写全限定类名。4.3 Maven 环境与 IDE 导入运行项目前需要确认本机 Maven 环境没问题。在命令行执行mvn -v能看到 Maven 版本和 JDK 版本就说明可用。项目里带了.mvn/wrapper目录这是 Maven Wrapper在没有全局 Maven 的情况下也可以用mvnw命令替代。导入 IDEA 时选择pom.xmlIDEA 会自动识别 Maven 工程Eclipse 则要选择 Import - Existing Maven Projects。依赖下载较慢时在 Maven 的 settings.xml 里配置国内镜像mirror idaliyunmaven/id mirrorOfcentral/mirrorOf urlhttps://maven.aliyun.com/repository/public/url /mirror镜像配置在上一级mirrors标签里改完重启 IDE 再刷新 Maven 项目即可。4.4 jar 包构建与命令行运行不想用 IDE 的朋友项目说明里给了完整的命令行运行方式。在项目根目录打开 cmd先打包再运行mvn clean package java -jar target/bookmanager-0.0.1-SNAPSHOT.jarmvn clean package会先删除 target 目录再重新编译打包最终生成的 jar 包在 target 目录下。jar 包文件名以项目 artifactId 和 version 为准如果 pom.xml 里的版本是 1.0.0文件名就是bookmanager-1.0.0.jar。运行后看到 Spring Boot 启动日志里出现 Tomcat started on port(s): 8080再打开浏览器访问localhost:8080。如果 8080 被其他程序占用可以在启动时指定另一个端口java -jar target/bookmanager-0.0.1-SNAPSHOT.jar --server.port8081整个部署链路的核心检查点可以用下表梳理检查内容验证方式MySQL 服务是否启动登录 Navicat 能否连上本地库library 库和表是否生成查看 library 库下是否存在 user、book、borrow_record 表application.yml 账号密码是否正确启动日志是否出现 Connection refused 或 Access denied项目能否正常编译mvn clean package是否 BUILD SUCCESS页面是否能打开浏览器访问 localhost:8080 返回登录页5. 把课程设计升级成面试项目的三个改法与排错清单课程设计能跑通只能说明功能完整想在简历和面试里加分还需要做几个低成本改造。第一个改法是逻辑删除把 book 表删除操作从delete from book改成update book set deleted 1 where id #{id}所有列表查询统一加deleted 0条件。面试官问「为什么用逻辑删除」时可以答保留历史数据借阅记录 join 时不会丢失书名也方便后续做数据恢复。第二个改法是统一返回体和全局异常。现在 Controller 直接返回 ModelAndView 或实体对象改造成ResultT结构包含 code、message、data 三个字段接口层和视图层分离。配合RestControllerAdvice写一个全局异常处理器把业务异常统一转成带错误码的 JSON这样 Ajax 请求才能拿到统一的错误结构。第三个改法值得专门说就是 MyBatis 缓存和参数比较的两个坑。一级缓存默认开启范围是同一个 SqlSession在 Spring 整合后每次请求都会新建 SqlSession所以跨请求感知不到缓存。二级缓存要显式开启如果涉及多表关联查询不建议开二级缓存因为缓存刷新只能感知当前 Mapper 对应的表join 的其他表数据更新后缓存里还是旧数据。另外前面提过的status 1比较问题在 OGNL 表达式里要格外小心阿里巴巴规范里也特别强调过 MyBatis 单个数字字符比较的坑。调试时如果条件没生效先在日志里打印 SQL确认 where 子句有没有拼接成功。最后是高频排错清单。启动时报时区错误检查连接串有没有serverTimezoneAsia/Shanghai。报 Access denied确认 application.yml 的用户名密码和 MySQL 实际账号一致。页面打开了但 css 样式全丢查看拦截器是否放行了/static/**。jar 包启动了但访问 404确认页面文件是不是放在src/main/resources/static目录下。验证功能是否完整的标准路径是这样用管理员账号登录添加一本测试图书退出后用读者账号登录检索这本书并完成借阅到借阅记录页面确认状态再尝试修改密码和注销。这一条链路走通课程设计演示部分基本就稳了。如果时间允许再补一个读者列表和借阅记录导出 Excel项目看起来会更完整。本文还有配套的精品资源点击获取