简介:这是一套基于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,请求分发交给 SpringMVC,SQL 控制交给 MyBatis。每一层都在你手边,出了问题能顺着源码查到底,这比直接套一个什么都帮你封装好的脚手架更能建立后端认知。
这套方案也适合两类人:一类是要交课程设计或者拿来当面试项目讲的在校生,另一类是刚转 Java 后端、需要一条完整主线工程来练手的开发者。它能回答的不只是“怎么查书、怎么借书”,还包括库存怎么扣、逾期状态怎么算、两个人同时借同一本剩书时系统为什么不会乱。文章后面给的建表、Mapper、事务和拦截器代码,都是照着生产环境的习惯写的,可以直接落到你的项目里。
2. SSM 三件套在图书系统里各扛什么活:分层选型与最小工程搭建
2.1 为什么选 SSM:Spring、SpringMVC、MyBatis 的职责边界
先说结论:图书管理系统业务不复杂,但 SQL 很灵活,查询借阅记录要关联用户表和图书表,统计逾期要按状态和时间段聚合,这类需求用 MyBatis 写 SQL 比用 JPA 自动生成的查询更可控。Spring 在这里管的是 Bean 的生命周期和事务,SpringMVC 管的是 URL 到 Controller 方法的映射,职责非常清晰。
| 框架 | 在图书系统里负责的事 | 不负责的事 |
|---|---|---|
| Spring | Bean 管理、事务、AOP 日志 | 请求路由、SQL 执行 |
| SpringMVC | 请求参数绑定、接口分发、统一异常处理 | 事务、SQL |
| MyBatis | SQL 映射、结果集转换、动态 SQL | 事务控制、拦截请求 |
这个拆分带来的最大好处是“替换成本低”。比如以后你觉得手写 XML 麻烦,可以把 MyBatis 换成 MyBatis-Plus 而不动 Service 层;想把 HTTP 层改成 Spring WebFlux,Controller 里业务逻辑也能原样搬走。相比之下 Spring Boot 全家桶把这些都压进自动配置里,省事但黑匣子多,出了问题反而更难定位。
2.2 最小工程骨架:pom 依赖与控制反转配置
一个能跑的 SSM 图书管理系统,核心依赖就这么几样,不要贪多。我这里给的是 Maven 坐标,版本选稳定组合,不追新:
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.31</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.31</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.16</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>Spring 5.3.31 和 MyBatis 3.5.16 是稳定的长线版本,能避免很多“版本号玄学”问题。接下来是 spring-mybatis.xml,这是整个系统能不能把 Mapper 注入进 Service 的关键:
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="url" value="jdbc:mysql://localhost:3306/library?useUnicode=true&characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="123456"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.library.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </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) { PageInfo<Book> page = bookService.pageBooks(pageNum, pageSize); return Result.ok(page); } }一次/book/list?pageNum=1&pageSize=10请求进来后,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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;我刻意没有建 user 表的 SQL,因为它通常还带角色和权限字段,按你自己的需求加就行,核心是 username 要有唯一索引。关于外键,我建议用逻辑外键而不是物理外键。物理外键会拖慢插入性能,而且图书管理系统的借阅记录日后可能要归档拆分,物理外键会给运维留麻烦。
一个容易踩的细节是due_time。不要用timestamp类型存还书截止时间,它的范围到 2038 年,虽然当前够用,但统一用datetime可以避免时间函数的隐式转换。借阅状态status用 tinyint 而不是字符串,因为后续要建索引,整数索引比字符串快不少。
3.2 Mapper 接口与 XML 映射:写一个不丢参数的联表查询
MyBatis 的 Mapper 接口和 XML 映射要严格绑定,接口方法名和 XML 里的 id 必须一致。下面这个查询是图书系统里最典型的联表场景:查某个人当前借了哪些书。
public interface BorrowMapper { List<BorrowVO> selectBorrowDetail(@Param("userId") Long userId, @Param("status") Integer status); }<select id="selectBorrowDetail" resultType="com.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 PageInfo<Book> pageBooks(Integer pageNum, Integer pageSize) { PageHelper.startPage(pageNum, pageSize); List<Book> books = bookMapper.selectList(); return new PageInfo<>(books); }PageHelper.startPage只对紧接着的下一条查询生效,所以必须放在bookMapper.selectList()之前,而且这条查询不能嵌套其他查询,否则会把嵌套的那条 SQL 也当成分页对象。我还建议selectList里强制ORDER BY id,MySQL 在无排序时返回顺序不稳定,少一条排序会让两次翻页出现重复数据。
4. 借书还书的并发与事务:把“库存差一本”这个问题按死在源头
4.1 借书流程的“查了再改”陷阱:一条 SQL 解决超借
很多人写借书逻辑时是这么干的:先select stock查剩余库存,判断大于 0,再执行update book set stock = stock - 1。这在单用户下没问题,但两个用户同时借最后一本时,两个线程都读到 stock = 1,都判断可以借,最后库存变成 -1。这就是典型的并发翻车。
正确的做法是把判断和扣减放在同一条 SQL 里:
<update id="decreaseStock"> UPDATE book SET stock = stock - 1 WHERE id = #{bookId} AND stock > 0 </update>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);这条 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.debug=true或者把日志级别调到 DEBUG,看日志里有没有打印Creating new SqlSession和 Mapper 绑定信息。
5.2 MyBatis 中#{}与${}用错:SQL 注入和隐式类型转换
现象:用${}拼排序字段时,接口传一个title能正常排序,传一个title DESC; DROP TABLE book就直接报错,严重的会丢数据。
原因:${}是字符串拼接,#{}是预编译占位符。很多人以为排序字段只能用${},确实排序字段无法参数化,但直接把用户输入拼进去就是送人头。
解决:做成白名单映射。前端传一个排序码,后端用 Map 或 switch 映射到固定字段名,比如sort=title映射成b.title,而不是直接把字符串拼进 SQL。这样既保留排序能力,又堵死注入。
5.3 PageHelper 分页串页问题:方言配置与分页时机的坑
现象:第一页正常,第二页返回的还是第一页数据,或者total等于 0。
原因:最常见的是PageHelper.startPage(2, 10)后面又执行了一条别的查询,分页插件只对紧跟的那条查询生效;也有的是多数据源环境下方言没设置,MySQL 的分页被按 Oracle 语法生成了。
解决:保证startPage后紧跟目标查询,中间不要有别的数据库操作。多数据源时,在配置里显式指定helper-dialect=mysql,不要让它自动检测。另外分页查询不要写成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 默认不启用 JavaTimeModule,LocalDateTime会被序列化成数组结构。
解决:在 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是一个持有ThreadLocal<User>的工具类,preHandle里放入用户,请求处理完在afterCompletion里清掉,防止线程池复用带来的数据串包。这样 Service 层想拿当前用户,直接UserContext.get()就行,不用层层传参。
我的习惯是给 BookController 的借书接口加一个操作日志,用 AOP 切面拦截@LogAnnotation,把 userId、bookId、时间、结果写入日志表。刚开始做图书系统时我也嫌这些麻烦,后来发现只要把登录拦截、ThreadLocal、操作日志这三件事搭好,后面加“借阅排行榜”“超期提醒”都只是加表和加查询的事。希望帮到你。
本文还有配套的精品资源,点击获取