简介:这份资源是《SSM的图书管理系统详解》配套的完整项目源码包,面向正在学习Java Web与SSM框架的学生、初级开发者及课程设计者,帮助其通过真实项目掌握Spring、SpringMVC、MyBatis的整合应用。压缩包为7z格式,共714个文件,约38.88MB,涵盖40个java源文件、80个class编译文件、50个jsp页面、82个js脚本,以及jar依赖、properties配置、sql脚本和前端字体图标等资源,完整呈现从后端逻辑到前端交互的工程结构。系统围绕用户借阅、公告查看与管理员对图书、读者、类型、借阅的全流程管理展开,包含实体类、Mapper映射、Service业务层与Controller控制层的分层实现。目前已有5243人学习下载,适合作为课程设计、毕业设计或SSM入门练手参考,读者可据此梳理项目分层结构、理解框架配置与数据库设计思路,并在此基础上进行功能扩展与二次开发。
1. 基于SSM图书管理系统:从“能跑”到“敢上线”的最后一公里
很多同学做基于SSM图书管理系统的第一反应是:不就是Spring + SpringMVC + MyBatis三件套,配几个CRUD吗?我当初也这么想,直到把代码丢给图书馆老师试用,第二天就被打回来——借书时两个人同时点“确认借阅”,库存扣成了负数;还书日期用new Date()硬编码,寒暑假一过全部逾期。SSM图书管理系统真正的门槛不在框架整合,而在“借还书”这条业务主线上:库存并发、借阅上限、逾期计算、权限隔离,每一个都能让Demo当场翻车。这篇笔记面向正在做课程设计、毕业设计或公司内部工具的同学,我会按“环境搭建→核心业务→避坑→进阶”的顺序,把一套能扛住真实使用的SSM图书管理系统拆开讲清楚。你跟着做,至少能少熬三个通宵。
2. 环境搭建与SSM整合:把三个框架焊死在一根配置线上
2.1 版本选型:为什么我坚持JDK 8 + Spring 5.3 + MyBatis 3.5
新手最容易在版本上翻车。网上教程有的用Spring 4,有的用Spring Boot,你如果直接抄,javax和jakarta混用会报ClassNotFoundException。我一般锁定这套组合:JDK 8(或11)、Spring 5.3.x、SpringMVC 5.3.x、MyBatis 3.5.x、MySQL 8.0、Druid 1.2.x、Tomcat 9。原因很直接:Spring 5.3是最后一个完整支持javax.servlet的稳定分支,和Tomcat 9天然匹配;MyBatis 3.5对LocalDate的支持已经完善,借还书日期不用再手动转java.sql.Date。MySQL 8的驱动类名是com.mysql.cj.jdbc.Driver,连接串必须带serverTimezone=Asia/Shanghai,否则借书时间会差8小时——这个坑我见过至少十个人踩。
Maven依赖里,spring-webmvc、spring-jdbc、mybatis、mybatis-spring、druid、mysql-connector-java、jstl这七个坐标一个都不能少。特别注意mybatis-spring的版本要和MyBatis主版本对齐,1.3.x配3.4,2.0.x配3.5,配错了启动就报NoClassDefFoundError。
2.2 用Java Config替代XML:三个配置类搞定整合
XML配置写起来长,但调试直观;Java Config简洁,但出错时堆栈不友好。我选Java Config,因为图书管理系统的Bean数量可控,而且能避免web.xml里contextConfigLocation路径写错这种低级问题。
// RootConfig.java:数据源、事务、MyBatis @Configuration @MapperScan("com.library.mapper") @EnableTransactionManagement public class RootConfig { @Bean public DataSource dataSource() { DruidDataSource ds = new DruidDataSource(); ds.setUrl("jdbc:mysql://localhost:3306/library?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"); ds.setUsername("root"); ds.setPassword("your_password"); ds.setInitialSize(5); ds.setMaxActive(20); return ds; } @Bean public SqlSessionFactory sqlSessionFactory(DataSource ds) throws Exception { SqlSessionFactoryBean factory = new SqlSessionFactoryBean(); factory.setDataSource(ds); // 下划线转驼峰,book_name -> bookName org.apache.ibatis.session.Configuration cfg = new org.apache.ibatis.session.Configuration(); cfg.setMapUnderscoreToCamelCase(true); factory.setConfiguration(cfg); return factory.getObject(); } @Bean public DataSourceTransactionManager txManager(DataSource ds) { return new DataSourceTransactionManager(ds); } }这段代码的关键在setMapUnderscoreToCamelCase(true)。数据库字段book_name、borrow_date如果不开启这个开关,MyBatis查出来的Book对象里对应属性全是null,前端显示空白,你会以为是SQL没执行,其实是映射没对上。DruidDataSource的maxActive=20是并发上限,图书管理系统并发不高,20足够,设太大反而浪费连接。
// WebConfig.java:SpringMVC视图与静态资源 @Configuration @EnableWebMvc @ComponentScan("com.library.controller") public class WebConfig implements WebMvcConfigurer { @Bean public InternalResourceViewResolver viewResolver() { InternalResourceViewResolver r = new InternalResourceViewResolver(); r.setPrefix("/WEB-INF/views/"); r.setSuffix(".jsp"); return r; } @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/static/**").addResourceLocations("/static/"); } }addResourceHandlers不写,CSS和JS全部404,页面裸奔。@EnableWebMvc会关闭SpringBoot式的默认配置,所以静态资源必须手动放行。
// WebAppInitializer.java:替代web.xml public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @Override protected Class<?>[] getRootConfigClasses() { return new Class[]{RootConfig.class}; } @Override protected Class<?>[] getServletConfigClasses() { return new Class[]{WebConfig.class}; } @Override protected String[] getServletMappings() { return new String[]{"/"}; } @Override protected Filter[] getServletFilters() { CharacterEncodingFilter f = new CharacterEncodingFilter("UTF-8", true); return new Filter[]{f}; } }CharacterEncodingFilter必须加,否则表单提交中文书名全是乱码。getServletMappings返回"/"而不是"/*",后者会拦截JSP请求导致404。
2.3 数据库建表:三张核心表与两个索引
图书管理系统的表不用多,book、reader、borrow_record三张就能跑通主线。borrow_record里status字段用0表示借出、1表示已还、2表示逾期,比用字符串省空间且好索引。
CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) UNIQUE NOT NULL, book_name VARCHAR(100) NOT NULL, author VARCHAR(50), stock INT DEFAULT 0, total INT DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, return_date DATE, status TINYINT DEFAULT 0, INDEX idx_reader_status (reader_id, status), INDEX idx_book_status (book_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;两个联合索引是给“查某人当前借了几本”和“查某书是否可借”准备的。没有索引,数据量到几万条时列表页会明显卡顿。stock和total分开存,stock是当前可借数,total是馆藏总数,还书时stock+1但不能超过total。
3. 借还书核心业务:并发扣减、借阅上限与逾期计算
3.1 借书接口:用乐观锁防止库存扣成负数
借书逻辑看着简单:查库存→库存减一→插入借阅记录。但两个人同时点,都查到stock=1,都减一,库存变-1。这是SSM图书管理系统最经典的翻车点。解决方案有两种:synchronized锁方法(单机有效,分布式失效)或数据库乐观锁。我选乐观锁,因为不阻塞、代码清晰。
// BookMapper.java @Update("UPDATE book SET stock = stock - 1 WHERE id = #{bookId} AND stock > 0") int decreaseStock(@Param("bookId") Integer bookId);// BorrowServiceImpl.java @Service public class BorrowServiceImpl implements BorrowService { @Autowired private BookMapper bookMapper; @Autowired private BorrowRecordMapper recordMapper; @Autowired private ReaderMapper readerMapper; @Override @Transactional(rollbackFor = Exception.class) public String borrow(Integer bookId, Integer readerId) { // 1. 检查借阅上限:最多5本 int borrowing = recordMapper.countBorrowing(readerId); if (borrowing >= 5) return "已达借阅上限"; // 2. 检查是否已借同一本书未还 if (recordMapper.countSameBook(readerId, bookId) > 0) return "请勿重复借阅"; // 3. 乐观锁扣库存 int rows = bookMapper.decreaseStock(bookId); if (rows == 0) return "库存不足"; // 4. 插入借阅记录 BorrowRecord r = new BorrowRecord(); r.setBookId(bookId); r.setReaderId(readerId); r.setBorrowDate(LocalDate.now()); r.setDueDate(LocalDate.now().plusDays(30)); r.setStatus(0); recordMapper.insert(r); return "success"; } }decreaseStock的WHERE stock > 0是灵魂。数据库行锁保证同一时刻只有一个事务能更新成功,返回0就说明库存没了,直接回滚。@Transactional的rollbackFor = Exception.class必须写,默认只回滚RuntimeException,如果中间抛了检查异常,库存扣了但记录没插,数据就脏了。
countBorrowing的SQL是SELECT COUNT(*) FROM borrow_record WHERE reader_id=#{readerId} AND status=0,配合前面的联合索引,走索引扫描很快。借阅上限5本是常见设定,你可以改成配置项,但别写死在代码里。
3.2 还书与逾期:日期计算别用毫秒数硬减
还书时要把status改成1,return_date填今天,同时stock+1。逾期判断用LocalDate的isAfter,不要用Date.getTime()相减,后者跨月跨年容易算错。
@Override @Transactional(rollbackFor = Exception.class) public String returnBook(Integer recordId) { BorrowRecord r = recordMapper.selectById(recordId); if (r == null || r.getStatus() != 0) return "记录无效"; LocalDate today = LocalDate.now(); r.setReturnDate(today); // 逾期判断:今天在应还日期之后 if (today.isAfter(r.getDueDate())) { long overdueDays = ChronoUnit.DAYS.between(r.getDueDate(), today); r.setStatus(2); // 标记逾期 // 可在此计算罚金:overdueDays * 0.2 } else { r.setStatus(1); } recordMapper.updateReturn(r); bookMapper.increaseStock(r.getBookId()); return "success"; }ChronoUnit.DAYS.between返回的是完整天数差,dueDate=2024-01-01、today=2024-01-03返回2,准确。increaseStock的SQL要加AND stock < total,防止还书时库存超过馆藏总数——虽然正常流程不会,但手动改库或重复还书时能兜底。
3.3 分页查询:PageHelper的坑与手写LIMIT的取舍
图书列表和借阅记录都要分页。PageHelper用起来一行代码,但它对count查询的优化有时会生成慢SQL,特别是多表关联时。我一般手写LIMIT,可控性更强。
<select id="selectByPage" resultType="com.library.entity.Book"> SELECT * FROM book <where> <if test="keyword != null and keyword != ''"> AND (book_name LIKE CONCAT('%',#{keyword},'%') OR author LIKE CONCAT('%',#{keyword},'%')) </if> </where> ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select>offset = (pageNum - 1) * pageSize,在Service层算好传进来。CONCAT('%',#{keyword},'%')用#{}而不是${},防SQL注入。ORDER BY id DESC让新书排前面,符合直觉。如果数据量超过50万,LIMIT大偏移会慢,那时再考虑游标分页,图书管理系统一般到不了这个量级。
4. 避坑与排查:那些让SSM图书管理系统当场翻车的瞬间
4.1 事务不生效:Service自己new出来的对象不受管
现象:借书时库存扣了,但插入借阅记录报错,库存没回滚。原因:在Controller里new BorrowServiceImpl(),这个对象不在Spring容器里,@Transactional是代理生效的,new出来的没有代理。解决:Controller里用@Autowired注入Service接口,确保从容器拿Bean。另外,@Transactional方法必须是public,private方法不生效。
4.2 中文乱码:GET请求和POST请求要分开治
现象:搜索框输入“Java编程”,后台收到JAVA编程。原因:POST乱码用CharacterEncodingFilter能解决,但Tomcat 8以后GET请求默认URI编码是UTF-8,如果server.xml里Connector没配URIEncoding="UTF-8",GET参数仍会乱。解决:Tomcat的server.xml中<Connector>加URIEncoding="UTF-8",或者搜索改用POST提交。我一般直接改Tomcat配置,一劳永逸。
4.3 MyBatis映射失败:字段名和属性名对不上
现象:Book对象查出来bookName是null,但数据库里book_name有值。原因:没开mapUnderscoreToCamelCase,或者开了但XML里写了resultMap又没配全。解决:在SqlSessionFactory里开启驼峰映射,或者用resultMap显式映射。两者选一,别混用。如果用了resultMap,<result column="book_name" property="bookName"/>一个字段都不能漏。
4.4 日期类型转换异常:前端传字符串,后端要Date
现象:新增图书时表单提交publishDate=2024-01-01,后台报Cannot convert value of type String to Date。原因:SpringMVC默认不支持字符串转LocalDate。解决:在实体类字段上加@DateTimeFormat(pattern = "yyyy-MM-dd"),或者在Controller里用@InitBinder注册编辑器。我倾向用注解,直观且每个字段独立控制。
4.5 静态资源被拦截:DispatcherServlet的锅
现象:JSP页面能打开,但CSS样式全丢,F12显示404。原因:web.xml或初始化类里DispatcherServlet映射到/,所有请求包括/static/css/style.css都进了SpringMVC,找不到对应Controller。解决:在WebConfig里重写addResourceHandlers,把/static/**映射到实际目录。如果用了<mvc:default-servlet-handler/>,注意它和@EnableWebMvc的优先级关系,Java Config下直接写addResourceHandlers最稳。
5. 权限隔离与借阅统计:把管理员和读者分开管
5.1 用拦截器做登录校验与角色判断
图书管理系统至少两种角色:管理员能增删改书,读者只能查和借。用SpringMVC拦截器在Controller方法执行前判断Session。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { HttpSession session = req.getSession(); User user = (User) session.getAttribute("loginUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login"); return false; } // 管理员接口校验 String uri = req.getRequestURI(); if (uri.contains("/admin/") && user.getRole() != 1) { resp.sendError(403, "无权限"); return false; } return true; } }在WebConfig里注册:registry.addInterceptor(new AuthInterceptor()).addPathPatterns("/**").excludePathPatterns("/login", "/static/**")。excludePathPatterns必须放行登录页和静态资源,否则登录页自己都被拦截,死循环。角色判断用URI包含/admin/来区分,简单有效,比注解扫描轻量。
5.2 借阅排行榜:一条SQL搞定月度热门图书
管理员需要看哪些书借得多。用borrow_record按book_id分组计数,关联book表拿书名。
SELECT b.book_name, COUNT(*) AS borrow_count FROM borrow_record r JOIN book b ON r.book_id = b.id WHERE r.borrow_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY r.book_id, b.book_name ORDER BY borrow_count DESC LIMIT 10;DATE_SUB(CURDATE(), INTERVAL 30 DAY)取30天前日期,配合borrow_date上的索引(如果没有,加一个)。GROUP BY要带上b.book_name,否则MySQL 8的ONLY_FULL_GROUP_BY模式会报错。这个查询在万级数据下毫秒级返回,直接渲染到管理后台首页。
5.3 读者借阅历史:分页与状态筛选的组合
读者登录后要看自己借了哪些书、哪些逾期。查询条件有reader_id、status、分页三个变量,用MyBatis动态SQL拼。
<select id="selectMyRecords" resultType="com.library.dto.BorrowDTO"> SELECT r.*, b.book_name, b.isbn FROM borrow_record r JOIN book b ON r.book_id = b.id WHERE r.reader_id = #{readerId} <if test="status != null"> AND r.status = #{status} </if> ORDER BY r.borrow_date DESC LIMIT #{offset}, #{pageSize} </select>BorrowDTO比直接返回BorrowRecord多两个字段,避免前端再查一次书。status为null时查全部,传0查在借,传2查逾期。ORDER BY borrow_date DESC让最近借的排前面。注意LIMIT参数用#{},MyBatis会预编译,安全。
6. 从课程设计到真实可用:三个让系统更耐用的技巧
第一个技巧是给借书接口加幂等。前端按钮点快了会发两次请求,乐观锁能防库存负数,但会插入两条借阅记录。我的做法是在borrow_record表加唯一索引UNIQUE KEY uk_reader_book_active (reader_id, book_id, status),但status会变,所以改用borrow_date+reader_id+book_id做业务去重:插入前先查当天是否已有同一本书的借阅记录。更彻底的是前端按钮点击后置灰,但后端兜底不能省。
第二个技巧是逾期罚金用配置表而不是硬编码。config表存fine_per_day、max_borrow_days、max_borrow_count三个键值对,Service启动时加载到内存,改规则不用重启。我见过把30天写死在代码里的,图书馆改成60天还得重新编译部署,运维想打人。
第三个技巧是日志。SSM默认用logback或log4j2,在logback.xml里把com.library.mapper的日志级别设为DEBUG,MyBatis会打印执行的SQL和参数。借书失败时看日志,是库存扣减返回0还是插入报错,一目了然。生产环境改成INFO,避免日志爆炸。
验证系统是否真的可用,我会做三件事:用JMeter开50个线程同时借同一本书,看库存是否精确扣到0且没有负数;把系统时间调到寒暑假前后,看逾期计算是否跨年正确;用不同角色账号登录,确认读者访问/admin/返回403。这三关过了,课程设计拿优,内部工具也能直接上线。
我自己踩过最深的坑是事务和乐观锁的顺序:先扣库存再查借阅上限,结果上限判断失败时库存已经扣了,虽然事务回滚了,但高并发下锁竞争白白增加。后来把校验全放前面,扣库存放最后,吞吐量明显提升。做图书管理系统别急着堆功能,把借还书这一条线做扎实,比加十个花哨页面都值。希望帮到你。
本文还有配套的精品资源,点击获取