
简介这是一套基于SSM框架Spring、SpringMVC、MyBatis开发的图书管理系统完整项目主要面向正在学习Java Web整合开发、需要完成课程设计或毕业设计的开发者。资源包内含3个文件项目源代码压缩包rar、使用手册txt和数据库脚本SQL压缩包整体大小为91.12MB其中源码解压后可直接导入IDE运行。数据库脚本导入MySQL后即可生成图书分类、用户信息、借阅记录等数据表使用手册则对系统部署、运行和常见问题给出了说明。系统已实现权限登录、邮件通知、答题模块、图书借阅与归还等完整功能其中借书模块涉及库存校验、用户信息和借阅规则的联动处理代码采用模块化分层设计业务逻辑与数据访问分离便于读者理解SSM框架中IoC容器、MVC流程和持久层映射的实际运用。目前已有3251人学习下载适合作为SSM框架实战入门与二次开发的参考案例。1. 图书管理系统为什么值得用 SSM 框架来做大部分初学 Java 的人都会把“图书管理系统”当成第一个完整项目但不少人做完之后只会讲“我会增删改查”。SSM 框架Spring SpringMVC MyBatis虽然看起来是上一个时代的标配但它恰好把图书管理系统的三个核心难题摊开了业务对象管理交给 Spring请求分发交给 SpringMVCSQL 控制交给 MyBatis。每一层都在你手边出了问题能顺着源码查到底这比直接套一个什么都帮你封装好的脚手架更能建立后端认知。这套方案也适合两类人一类是要交课程设计或者拿来当面试项目讲的在校生另一类是刚转 Java 后端、需要一条完整主线工程来练手的开发者。它能回答的不只是“怎么查书、怎么借书”还包括库存怎么扣、逾期状态怎么算、两个人同时借同一本剩书时系统为什么不会乱。文章后面给的建表、Mapper、事务和拦截器代码都是照着生产环境的习惯写的可以直接落到你的项目里。2. SSM 三件套在图书系统里各扛什么活分层选型与最小工程搭建2.1 为什么选 SSMSpring、SpringMVC、MyBatis 的职责边界先说结论图书管理系统业务不复杂但 SQL 很灵活查询借阅记录要关联用户表和图书表统计逾期要按状态和时间段聚合这类需求用 MyBatis 写 SQL 比用 JPA 自动生成的查询更可控。Spring 在这里管的是 Bean 的生命周期和事务SpringMVC 管的是 URL 到 Controller 方法的映射职责非常清晰。框架在图书系统里负责的事不负责的事SpringBean 管理、事务、AOP 日志请求路由、SQL 执行SpringMVC请求参数绑定、接口分发、统一异常处理事务、SQLMyBatisSQL 映射、结果集转换、动态 SQL事务控制、拦截请求这个拆分带来的最大好处是“替换成本低”。比如以后你觉得手写 XML 麻烦可以把 MyBatis 换成 MyBatis-Plus 而不动 Service 层想把 HTTP 层改成 Spring WebFluxController 里业务逻辑也能原样搬走。相比之下 Spring Boot 全家桶把这些都压进自动配置里省事但黑匣子多出了问题反而更难定位。2.2 最小工程骨架pom 依赖与控制反转配置一个能跑的 SSM 图书管理系统核心依赖就这么几样不要贪多。我这里给的是 Maven 坐标版本选稳定组合不追新dependencies dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version5.3.31/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.31/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.16/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependenciesSpring 5.3.31 和 MyBatis 3.5.16 是稳定的长线版本能避免很多“版本号玄学”问题。接下来是 spring-mybatis.xml这是整个系统能不能把 Mapper 注入进 Service 的关键bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property nameurl valuejdbc:mysql://localhost:3306/library?useUnicodetrueamp;characterEncodingutf8/ property nameusername valueroot/ property namepassword value123456/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.library.mapper/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean这里面mapperLocations指定了 XML 映射文件的位置basePackage指定了 Mapper 接口所在的包扫描到之后会自动生成代理对象Service 里直接Autowired就能用不用手写实现类。事务管理器必须单独配后面讲借书还书的回滚全靠它。2.3 SpringMVC 配置与请求链路从一个查询接口看全流程SpringMVC 的配置在 web.xml 或 Java 配置里注册 DispatcherServlet。下面是一个最常见的 Controller 写法看着简单但整条链路值得拆开讲RestController RequestMapping(/book) public class BookController { Autowired private BookService bookService; GetMapping(/list) public Result list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize) { PageInfoBook page bookService.pageBooks(pageNum, pageSize); return Result.ok(page); } }一次/book/list?pageNum1pageSize10请求进来后DispatcherServlet 先通过 HandlerMapping 找到list方法然后由参数解析器把pageNum和pageSize绑定成 Integer再执行方法返回值通过 Jackson 转成 JSON 写回浏览器。这中间任何一个环节报错都能从 SpringMVC 的源码里找到对应处理类这就是 SSM 适合练手的原因。我一般会把分页参数默认值写在RequestParam上而不是在 Service 里判空这样能减少一层无用代码。Controller 里不写任何 SQL 相关的东西只负责收参数和返回结果这是 SSM 项目里默认的纪律。3. 图书借还的数据库设计与 MyBatis 落库从建表到联表查询3.1 图书管理系统的三张表从字段到索引设计一次到位图书系统最核心的三张表是图书表、用户表、借阅表。图书表要单独存一个 ISBN 字段因为同一本书可能有多本馆藏书名不唯一ISBN 才是唯一标识。借阅表是关联表记录谁在哪天借了哪本书、什么时候该还。CREATE TABLE book ( id bigint NOT NULL AUTO_INCREMENT, isbn varchar(32) NOT NULL COMMENT ISBN号全局唯一, title varchar(128) NOT NULL COMMENT 书名, author varchar(64) NOT NULL DEFAULT , publisher varchar(64) NOT NULL DEFAULT , stock int NOT NULL DEFAULT 1 COMMENT 当前可借库存, total_stock int NOT NULL DEFAULT 1 COMMENT 馆藏总量, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_isbn (isbn) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE borrow ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, book_id bigint NOT NULL, borrow_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, due_time datetime NOT NULL, return_time datetime DEFAULT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0借阅中 1已还 2逾期未还, PRIMARY KEY (id), KEY idx_book_status (book_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;我刻意没有建 user 表的 SQL因为它通常还带角色和权限字段按你自己的需求加就行核心是 username 要有唯一索引。关于外键我建议用逻辑外键而不是物理外键。物理外键会拖慢插入性能而且图书管理系统的借阅记录日后可能要归档拆分物理外键会给运维留麻烦。一个容易踩的细节是due_time。不要用timestamp类型存还书截止时间它的范围到 2038 年虽然当前够用但统一用datetime可以避免时间函数的隐式转换。借阅状态status用 tinyint 而不是字符串因为后续要建索引整数索引比字符串快不少。3.2 Mapper 接口与 XML 映射写一个不丢参数的联表查询MyBatis 的 Mapper 接口和 XML 映射要严格绑定接口方法名和 XML 里的 id 必须一致。下面这个查询是图书系统里最典型的联表场景查某个人当前借了哪些书。public interface BorrowMapper { ListBorrowVO selectBorrowDetail(Param(userId) Long userId, Param(status) Integer status); }select idselectBorrowDetail resultTypecom.example.library.vo.BorrowVO SELECT b.id AS borrowId, b.book_id AS bookId, bk.title AS bookTitle, u.username AS username, b.borrow_time AS borrowTime, b.due_time AS dueTime, b.return_time AS returnTime, b.status FROM borrow b JOIN book bk ON b.book_id bk.id JOIN user u ON b.user_id u.id WHERE b.user_id #{userId} AND b.status #{status} ORDER BY b.borrow_time DESC /select这里#{}会被 MyBatis 编译成 PreparedStatement 的占位符不会拼接进 SQL所以不存在注入问题。返回对象直接用 VO字段名用别名对应省掉 resultMap 的写法。注意borrowTime返回的是数据库里的datetime对应 Java 实体里用LocalDateTime接收不要再用Date后面序列化那节还会提这个问题。3.3 分页查询PageHelper 接入与排序稳定性图书列表和借阅记录都要分页我一般用 PageHelper它是 MyBatis 生态里最常用的分页组件。用法很简单关键是不能用错位置。public PageInfoBook pageBooks(Integer pageNum, Integer pageSize) { PageHelper.startPage(pageNum, pageSize); ListBook books bookMapper.selectList(); return new PageInfo(books); }PageHelper.startPage只对紧接着的下一条查询生效所以必须放在bookMapper.selectList()之前而且这条查询不能嵌套其他查询否则会把嵌套的那条 SQL 也当成分页对象。我还建议selectList里强制ORDER BY idMySQL 在无排序时返回顺序不稳定少一条排序会让两次翻页出现重复数据。4. 借书还书的并发与事务把“库存差一本”这个问题按死在源头4.1 借书流程的“查了再改”陷阱一条 SQL 解决超借很多人写借书逻辑时是这么干的先select stock查剩余库存判断大于 0再执行update book set stock stock - 1。这在单用户下没问题但两个用户同时借最后一本时两个线程都读到 stock 1都判断可以借最后库存变成 -1。这就是典型的并发翻车。正确的做法是把判断和扣减放在同一条 SQL 里update iddecreaseStock UPDATE book SET stock stock - 1 WHERE id #{bookId} AND stock 0 /updateint rows bookMapper.decreaseStock(bookId); if (rows 0) { throw new BizException(这本书已经被借完了); } BorrowRecord record new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setDueTime(dueTime); borrowMapper.insert(record);这条 UPDATE 是原子操作MySQL 的行锁会保证同一时刻只有一个事务能改这条数据stock 变成 0 之后第二个事务影响行数为 0直接抛出异常。这样写比先查再改少了一次网络往返也把并发风险消灭在 SQL 层面是实战里最可靠的方案。4.2 Transactional 在 SSM 里怎么才不失效配置、传播行为与回滚策略上面借书的两个操作扣库存和插入借阅记录必须在一个事务里。如果扣库存成功插入借阅记录失败库存应该回滚否则书没借出去但库存少了账对不上。Spring 的Transactional能解决这事但在 SSM 里要注意几件事。Service public class BorrowServiceImpl implements BorrowService { Override Transactional(rollbackFor Exception.class) public void borrowBook(Long userId, Long bookId, LocalDateTime dueTime) { int rows bookMapper.decreaseStock(bookId); if (rows 0) { throw new BizException(这本书已经被借完了); } BorrowRecord record new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setDueTime(dueTime); borrowMapper.insert(record); } }rollbackFor Exception.class必须写因为 Spring 默认只对 RuntimeException 回滚图书管理系统里自定义的BizException如果继承的是 Exception不写这个参数事务就不动。事务要生效还要求Transactional的方法必须被 Spring 代理调用也就是从外部调用borrowBook()才有效。如果是同一个类里borrowBook调另一个Transactional方法那叫自调用事务会失效得通过注入自身或者把事务方法拆到另一个 Service 里去解决。4.3 还书时逾期状态怎么算用 Java 算还是用 SQL 算还书流程比借书简单但逾期判断有讲究。归还时先更新 borrow 表的状态再恢复库存同样放一个事务里。if (now.isAfter(record.getDueTime())) { borrowMapper.updateStatus(record.getId(), 2, now); } else { borrowMapper.updateStatus(record.getId(), 1, now); } bookMapper.increaseStock(record.getBookId());业务场景里还书当天算不算逾期不同图书馆规则不一样所以我不建议在 SQL 里写死DATEDIFF而是让应用层决定因为应用层能结合运营规则比如宽限期灵活调整。效率上没什么差别一次联表查询加一个状态更新性能瓶颈不在这里。5. SSM 图书管理系统避坑指南从启动报错到分页不对5 个实际踩坑记录5.1 启动时 Bean 创建失败Mapper 扫描路径写错现象项目启动直接报NoSuchBeanDefinitionException提示找不到某个 Mapper 类型的 Bean。原因MapperScannerConfigurer里的basePackage写的是com.example.library.mapper但接口实际在com.example.library.dao包下面扫描落空。或者mapperLocations路径写成了classpath:mapper少了/*.xml导致 XML 没加载。解决先确认接口包名和配置一致再把mapperLocations写成classpath:mapper/*.xml。最直接的排查办法是启动时加-Dmybatis.debugtrue或者把日志级别调到 DEBUG看日志里有没有打印Creating new SqlSession和 Mapper 绑定信息。5.2 MyBatis 中#{}与${}用错SQL 注入和隐式类型转换现象用${}拼排序字段时接口传一个title能正常排序传一个title DESC; DROP TABLE book就直接报错严重的会丢数据。原因${}是字符串拼接#{}是预编译占位符。很多人以为排序字段只能用${}确实排序字段无法参数化但直接把用户输入拼进去就是送人头。解决做成白名单映射。前端传一个排序码后端用 Map 或 switch 映射到固定字段名比如sorttitle映射成b.title而不是直接把字符串拼进 SQL。这样既保留排序能力又堵死注入。5.3 PageHelper 分页串页问题方言配置与分页时机的坑现象第一页正常第二页返回的还是第一页数据或者total等于 0。原因最常见的是PageHelper.startPage(2, 10)后面又执行了一条别的查询分页插件只对紧跟的那条查询生效也有的是多数据源环境下方言没设置MySQL 的分页被按 Oracle 语法生成了。解决保证startPage后紧跟目标查询中间不要有别的数据库操作。多数据源时在配置里显式指定helper-dialectmysql不要让它自动检测。另外分页查询不要写成select *拼 union分页插件对复杂 SQL 的 count 优化经常出问题遇到就单独写 count 查询。5.4 事务一直不回滚异常被吞与自调用现象用户借书时扣了库存但借阅记录插入失败数据库里库存还是少了一本。原因代码里把insert包在 try-catch 里异常被吞掉Spring 不知道发生了错误自然不会回滚。或者是上面提到的同类自调用问题。解决事务方法内不要捕获异常让异常抛出去交给事务代理处理。如果确实要在借书失败时记录日志可以用try-catch-finally但 catch 里必须throw new RuntimeException(e)或者TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。我一般直接用后者回滚意图更明确。5.5 LocalDateTime 序列化报错Java 8 时间类型与 Jackson现象接口返回数据里时间字段变成了一串数字数组或者直接报Java 8 date/time type not supported by default。原因SpringMVC 默认用 Jackson 做 JSON 序列化而 Jackson 默认不启用 JavaTimeModuleLocalDateTime会被序列化成数组结构。解决在 SpringMVC 配置里注册 JavaTimeModule并统一日期格式。这样所有时间字段回到前端都是yyyy-MM-dd HH:mm:ss的字符串前端不用再做转换。Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; } }6. 收尾技巧用拦截器做登录态校验与操作日志让图书系统更像生产项目很多学生项目把登录判断写在每个 Controller 里每个方法上来先if (session.getAttribute(user) null)再加权限时满文件改非常痛苦。我一般会加一个 HandlerInterceptor把登录校验集中拦截再用 ThreadLocal 保存当前操作人配合日志切面记录谁在什么时候借了哪本书。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User user (User) session.getAttribute(loginUser); if (user null) { response.sendRedirect(request.getContextPath() /login); return false; } UserContext.set(user); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }注册拦截器时要注意排除登录接口和静态资源Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /css/**, /js/**, /book/list); } }UserContext是一个持有ThreadLocalUser的工具类preHandle里放入用户请求处理完在afterCompletion里清掉防止线程池复用带来的数据串包。这样 Service 层想拿当前用户直接UserContext.get()就行不用层层传参。我的习惯是给 BookController 的借书接口加一个操作日志用 AOP 切面拦截LogAnnotation把 userId、bookId、时间、结果写入日志表。刚开始做图书系统时我也嫌这些麻烦后来发现只要把登录拦截、ThreadLocal、操作日志这三件事搭好后面加“借阅排行榜”“超期提醒”都只是加表和加查询的事。希望帮到你。本文还有配套的精品资源点击获取