简介:面向Java Web入门与课程设计场景的图书管理系统完整源码,采用SpringBoot整合MyBatis持久层,并以JSP负责视图渲染,覆盖图书增删改查、分类管理、借阅归还与分页展示等核心业务,适合高校学生完成课程设计、毕业设计参考,也适合开发者熟悉MVC分层与数据库映射。压缩包共1192个文件,约95.3MB,包含648个class、173个jsp、148个xml、65个jar及51个java源文件,另有properties配置、js/css静态资源与图片素材,兼顾可运行产物与二次开发所需源码。目前已有2208人学习下载。项目体现SpringBoot自动配置、MyBatis Mapper映射、PageHelper分页、Spring Security权限控制与jar包部署等实践点,可帮助读者对照理解控制器、服务层、DAO与JSP页面之间的调用关系,并借鉴表结构设计与前后端数据传递方式。
1. 从一次借书卡住说起:这套图书管理系统要解决什么
学期末整理借阅台账,管理员要在纸质登记本和 Excel 之间来回抄:一本书被谁借走、还剩几本、谁逾期没还,全靠人工核对,一处对不上就得从头翻。这套基于 springboot + mybatis + jsp 的图书管理系统,就是把这条线下流程搬进浏览器——读者查书、借书、还书,管理员上架、下架、盯逾期。它适合两类人:要交毕设或课程设计、需要一个结构完整还能跑起来的学生;以及想用轻量技术栈快速搭一套后台的从业者,jsp 管视图、mybatis 写 SQL,改起来不必翻几层封装。
2. 需求拆解与数据模型:三张主表撑起的借阅链路
做这类系统,最常见的翻车不是代码写不出来,而是表结构一开始就没想清楚。图书表里塞一个“借阅人”字段,看着省事,等要查借阅历史、算逾期天数、支持一人借多本的时候,就得整表返工。我一般的做法是先把角色和用例定下来,再让表结构去匹配用例,最后用状态机把借阅记录的生命周期卡死。
2.1 角色与用例:管理员和读者各自看到什么
角色只有两个,但权限边界要在设计阶段就画清楚,否则写到一半发现读者能进后台,就得回头补拦截器。
管理员侧的用例包括:登录与注销、图书的增删改查、分类维护、读者账号管理、借书与还书的柜台办理、逾期列表、简单的借阅统计。读者侧的用例包括:注册登录、按书名/作者/分类检索、查看图书是否可借、查看自己的借阅记录与应还日期。
| 用例 | 角色 | 关键约束 |
|---|---|---|
| 图书上架/下架 | 管理员 | 下架前校验该书无未归还记录 |
| 借书办理 | 管理员 | 库存大于 0,同一读者同一本书不可重复借 |
| 还书办理 | 管理员 | 记录状态必须为“借阅中”或“逾期” |
| 图书检索 | 读者 | 只展示上架状态的书 |
| 我的借阅 | 读者 | 只能看到 reader_id 等于自己的记录 |
这张表不是走形式,它直接决定了后面接口的入参和校验点。比如“同一读者同一本书不可重复借”这条,如果不在需求阶段写下来,代码里十有八九会漏,最后演示时被问一句“我借两本同样的书会怎样”就答不上来。
2.2 表结构设计:图书、读者、借阅记录与状态字段
三张主表加一张分类表就够了,字段不要贪多。图书表里放库存和总藏书量,借阅记录表里放状态和时间,两者通过 book_id 关联,读者通过 reader_id 关联。
-- 图书表:stock 是当前可借数,total 是馆藏总数 CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE COMMENT '国际标准书号,做唯一约束防重复录入', title VARCHAR(120) NOT NULL COMMENT '书名', author VARCHAR(60) NOT NULL, category_id BIGINT NOT NULL COMMENT '分类外键', total INT NOT NULL DEFAULT 1 COMMENT '馆藏总数', stock INT NOT NULL DEFAULT 1 COMMENT '当前可借数量,借出减一、归还加一', status TINYINT NOT NULL DEFAULT 1 COMMENT '1 上架 0 下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 借阅记录表:状态和时间字段是逾期统计的依据 CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, reader_id BIGINT NOT NULL, borrow_time DATETIME NOT NULL COMMENT '借出时间', due_time DATETIME NOT NULL COMMENT '应还时间,借出时按 30 天算好落库', return_time DATETIME NULL COMMENT '实际归还时间,未还为 NULL', status TINYINT NOT NULL DEFAULT 0 COMMENT '0 借阅中 1 已归还 2 逾期 3 遗失', KEY idx_reader (reader_id), KEY idx_book (book_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;due_time一定要在借出时就落库,不要在查询时用borrow_time + 30 天现算。现算看起来省一个字段,但一旦管理员手动延期,或者要按应还日期排序,就得改 SQL;落库之后,逾期判断退化成一次due_time < NOW()的比较,简单且不会算错。status用数字而不是中文字符串,是为了索引效率和后续加状态时不用改字段长度,代价是前端要维护一份映射,这个成本可以接受。
2.3 借阅状态机:从借出到归还的四种状态流转
状态字段最怕变成“想写什么写什么”,所以要把合法流转写死:借出时写 0,归还时把 0 或 2 改成 1,定时任务把到期未还的 0 刷成 2,确认丢书时改成 3。除这四条路径外,任何状态变更都视为非法。
- 0 → 1:正常归还,同时
stock + 1 - 0 → 2:到期未还,由每日定时任务扫描
due_time < NOW() AND status = 0 - 2 → 1:逾期后归还,库存照加,记录保留逾期痕迹
- 0/2 → 3:遗失登记,库存不加回,作为馆藏损耗
提示:状态更新的 SQL 一律带上原状态作为条件,例如
WHERE id = ? AND status IN (0,2),靠影响行数判断是否成功,避免两个管理员同时点归还导致库存加两次。
这套状态机在后面写归还接口时是直接照着翻译成 SQL 的,需求阶段多想十分钟,编码阶段能省一小时。
3. 技术选型与工程骨架:springboot + mybatis + jsp 怎么搭才不别扭
SpringBoot 配 JSP 是这套组合里最容易让人怀疑人生的部分,因为官方默认推荐模板引擎,JSP 需要额外的依赖和目录约定。但只要把打包方式和视图前缀配好,它跑起来很稳,而且对课程设计这类需要“看得见页面、改得动页面”的场景,反而比前后端分离省事。
3.1 为什么是 JSP:与模板引擎、前后端分离方案的取舍
三种方案我都用过,差别主要在调试链路和答辩时的解释成本上。
| 方案 | 页面改动 | 调试链路 | 适合场景 |
|---|---|---|---|
| JSP + JSTL | 改完刷新即可 | 请求直达 Controller,断点好打 | 课程设计、后台管理、需要快速演示 |
| Thymeleaf | 需重启或热部署 | 同上,语法更现代 | 新建项目、HTML 原型复用 |
| 前后端分离 | 需跑两个服务 | 跨域、代理、双端联调 | 团队协作、有前端人力 |
选 JSP 的核心理由不是它先进,而是它把“数据怎么到页面”这件事摊得很平:Controller 往 Model 里塞一个 list,JSP 用<c:forEach>循环出来,中间没有序列化、没有跨域、没有前端构建。对需要在一周内交出可运行系统的场景,这条链路最短。代价是页面逻辑容易和 Java 代码混在一起,所以后面要立一条规矩:JSP 里只做展示和简单判断,取数一律放 Controller。
3.2 目录结构与依赖:webapp 目录、jasper 依赖与打包方式
JSP 要能被解析,必须满足两个条件:依赖里有 jasper,编译产物里能找得到 webapp 目录。前者靠 pom,后者靠打包方式和目录位置。
<!-- 视图解析 JSP 必需,缺了它访问页面会 404 --> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <scope>provided</scope> </dependency> <!-- JSTL 标签库,用于 c:forEach、c:if --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> </dependency>spring: mvc: view: prefix: /WEB-INF/jsp/ # JSP 放在 webapp/WEB-INF/jsp 下,外部无法直接访问 suffix: .jsp datasource: url: jdbc:mysql://localhost:3306/library?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 下划线字段自动映射驼峰属性prefix指向/WEB-INF/jsp/有安全意义:放在 WEB-INF 下的资源不能被浏览器直接请求,只能由 Controller 转发,避免有人绕过权限直接访问页面。map-underscore-to-camel-case打开后,borrow_time会自动映射到borrowTime,能省掉大量 resultMap 手写。打包建议用 war 或者引入 jasper 后打成可执行 jar,两种都行,但目录必须是src/main/webapp,放到resources下一定 404。
3.3 分层约定:controller、service、mapper 各自不该做什么
分层不是形式主义,它决定了出问题时你能不能快速定位。我一般立三条硬规矩:Controller 不写业务判断,Service 不碰 HttpServletRequest,Mapper 里不出现业务分支。
@Controller @RequestMapping("/book") public class BookController { @Resource private BookService bookService; // 只做参数接收和视图跳转,业务交给 service @GetMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, String keyword, Model model) { PageInfo<Book> page = bookService.page(pageNum, pageSize, keyword); model.addAttribute("page", page); model.addAttribute("keyword", keyword); return "book/list"; // 对应 /WEB-INF/jsp/book/list.jsp } }pageNum和pageSize给了默认值,是为了让不带参数的访问也能出结果,避免演示时一个空链接报 500。keyword原样回填到 model,是为了翻页时搜索条件不丢——这个细节不加,翻到第二页搜索就失效了,属于典型的“自己测不出来、别人一用就发现”的问题。Service 层负责分页和条件拼装,Mapper 只负责 SQL,职责清楚之后,改一个查询条件不需要动页面。
4. 核心模块落地:借阅、归还与库存扣减的代码骨架
整套系统真正有技术含量的地方就两处:库存不能扣成负数,借阅记录和库存必须同时成功或同时失败。其余都是增删改查的重复劳动。
4.1 库存扣减:一条带条件的 update 比加锁更省事
库存扣减有三种写法:先查再改、synchronized加锁、带条件的原子更新。前两种在单机小流量下也能跑,但只要有两个管理员同时点借书,就会扣成负数或者互相覆盖。
-- 扣减库存:stock > 0 作为条件,影响行数为 0 说明已被借空 UPDATE book SET stock = stock - 1 WHERE id = #{bookId} AND stock > 0 AND status = 1;// 返回受影响行数,0 表示库存不足或书已下架 int rows = bookMapper.decreaseStock(bookId); if (rows == 0) { throw new BizException("该书已无可借库存"); }这条 SQL 把“判断”和“修改”合并成一次数据库操作,InnoDB 的行锁保证同一行不会并发扣减,不需要应用层加锁。status = 1一起带上,是为了防止已下架的书被借走。业务异常抛出去由统一异常处理器接管,转成页面提示,而不是让它变成 500 白页。
4.2 借阅事务:service 层如何保证记录与库存同时成功
借书是典型的多表写操作:插一条借阅记录、扣一次库存。任何一步失败都必须回滚,否则会出现“有记录没扣库存”或“扣了库存没记录”的脏数据。
@Service public class BorrowServiceImpl implements BorrowService { @Resource private BookMapper bookMapper; @Resource private BorrowRecordMapper recordMapper; // rollbackFor 显式声明 Exception,避免受检异常不回滚 @Override @Transactional(rollbackFor = Exception.class) public void borrow(Long bookId, Long readerId) { // 1. 先扣库存,条件更新失败直接抛异常中断 if (bookMapper.decreaseStock(bookId) == 0) { throw new BizException("库存不足,借阅失败"); } // 2. 再写借阅记录,应还日期在落库时就算好 BorrowRecord record = new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowTime(LocalDateTime.now()); record.setDueTime(LocalDateTime.now().plusDays(30)); record.setStatus(0); recordMapper.insert(record); } }顺序很关键:先扣库存再插记录。反过来的话,插入成功但扣减失败,回滚同样能保证一致,但日志里会先出现一条记录再消失,排查时容易看花眼。rollbackFor = Exception.class不能省,Spring 默认只对运行时异常回滚,一旦你在方法里抛了受检异常,事务会静默提交,这就是很多人遇到的“明明报错了数据还是写进去了”。业务异常继承 RuntimeException 也可以,但显式声明更保险。
4.3 登录与权限拦截:session + HandlerInterceptor 的最小实现
JSP 项目用 session 存登录态最直接,拦截器负责把未登录请求挡在门外。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { // 未登录跳登录页,注意要带上项目上下文路径 response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/doLogin", "/css/**", "/js/**", "/images/**"); } }excludePathPatterns里必须放静态资源,否则登录页的样式会被拦掉,页面裸奔。重定向用getContextPath()拼前缀,是因为项目如果部署在非根路径下,写死/login会跳到错误地址。拦截器只管“有没有登录”,至于“是不是管理员”,我在拦截器里再读一次 session 中的角色做二次判断,比在每個 Controller 里写 if 干净得多。
4.4 分页与查询:PageHelper 加动态 SQL 的组合
图书列表要支持按书名模糊查、按分类筛选、按是否可借过滤,条件组合起来有七八种,全部写死 SQL 会很难维护。
<select id="selectByCondition" resultType="com.demo.entity.Book"> SELECT * FROM book <where> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="onlyAvailable != null and onlyAvailable"> AND stock > 0 </if> </where> ORDER BY id DESC </select>public PageInfo<Book> page(Integer pageNum, Integer pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); List<Book> list = bookMapper.selectByCondition(keyword); return new PageInfo<>(list); }PageHelper.startPage必须紧挨着查询方法调用,中间插任何一条别的查询,分页参数就会被那条 SQL 消费掉,这是最常见的翻车点之一。<where>标签会自动去掉多余的 AND,不用手写WHERE 1=1。>是 XML 里的转义写法,直接写>在部分解析器下会报错。
5. 避坑与排查:这类项目最容易翻车的五个地方
下面五条都是我在这类项目里真实踩过或帮别人排查过的,现象和原因写清楚,照着对一遍能省下大半天。
5.1 访问页面 404,Controller 明明返回了视图名
现象:接口日志显示方法执行完了,返回book/list,浏览器却是 404 或 Whitelabel 页面。
原因基本只有三个:JSP 文件没放在src/main/webapp/WEB-INF/jsp/下;pom 里缺tomcat-embed-jasper;prefix配成了/WEB-INF/jsp少了结尾斜杠,拼出来变成/WEB-INF/jspbook/list.jsp。
解决:先看编译产物 target 目录里有没有 jsp 文件,没有就是目录放错了;有的话检查依赖和前缀,前缀建议固定写成/WEB-INF/jsp/带斜杠,省得记。
5.2 表单提交中文变成问号,或者存进库是乱码
现象:页面上输入“数据结构”,列表里显示 ???,或者数据库里存的是乱码。
原因通常有两层:数据库和表的字符集不是 utf8mb4;或者 JDBC URL 少了characterEncoding=utf8。还有一种少见情况是 JSP 页面顶部没写<%@ page contentType="text/html;charset=UTF-8" %>。
解决:建库建表统一CHARSET=utf8mb4,URL 里带上useUnicode=true&characterEncoding=utf8,JSP 首行声明编码。三处都对齐,基本不会再出问题。
5.3 事务加了注解却不回滚
现象:借书接口抛了异常,页面也报错了,回头一查借阅记录插进去了,库存却没扣。
原因:@Transactional加在了 Controller 上,或者加在 Service 的私有方法上,或者同类内部方法直接调用——这三种情况 Spring 的代理都拦不到,注解等于没写。
解决:注解只加在 public 的 Service 方法上,且必须从外部调用(注入 Bean 后调,不要this.xxx())。排查时在方法里打一行日志,看事务是否真的开启。
5.4 日期字段存取差 8 小时
现象:借出时间存进去是 14:00,查出来变成 06:00。
原因:JDBC URL 没指定serverTimezone,驱动用了 UTC;或者实体类用LocalDateTime但 MyBatis 版本较老,走了默认的 Date 转换。
解决:URL 里补serverTimezone=Asia/Shanghai,实体统一用LocalDateTime并在 MyBatis 3.4.5 以上版本使用,基本能对齐。涉及统计按天分组时,最好在 SQL 里用DATE()明确截断,别依赖应用层。
5.5 登录页样式丢失,静态资源全被拦截
现象:登录页能打开,但没有样式,F12 里 css 请求返回的是登录页的 HTML。
原因:拦截器addPathPatterns("/**")把静态资源也包进去了,未登录状态下被重定向。
解决:excludePathPatterns里显式放行/css/**、/js/**、/images/**,并且确认静态资源放在src/main/resources/static下而不是 webapp 里。
6. 验收与答辩:用一份可复现的检查清单过一遍
答辩最怕的不是功能少,而是演示到一半系统崩了或者数据对不上。所以我在交之前一定按下面这张表自己走一遍,每一条都点开、点完、改完再打勾。
| 检查项 | 操作 | 通过标准 |
|---|---|---|
| 库存边界 | 把某本书 stock 改成 1,连续借两次 | 第二次提示库存不足,库存停在 0,不出现负数 |
| 事务完整性 | 借书时手动在插入记录后抛异常 | 借阅记录不落库,库存回到借出前 |
| 重复借阅 | 同一读者同一本书借两次 | 第二次被拦截并给出提示 |
| 权限边界 | 未登录直接访问/book/list | 跳转登录页,不泄露数据 |
| 逾期统计 | 把某条记录 due_time 改成昨天 | 逾期列表能查到,状态显示正确 |
| 分页与搜索 | 搜索关键词后翻到第二页 | 关键词保持,页码正确,不出现重复数据 |
演示顺序也有讲究:先跑通“查书—借书—还书”这条主链路,让评委看到完整闭环,再展示逾期列表和权限拦截这些加分项。库存和事务这类看不见的地方,用语言补一句“这里做了条件更新和事务控制,防止超借”,比现场制造并发更有效。
被问到“为什么用 JSP 不用 Vue”时,别硬吹技术先进,就说清楚取舍:项目需要的是短时间内交付一个能跑、能改、部署简单的系统,JSP 把请求到页面的链路压到最短,代价是页面逻辑要克制。能讲清代价的选型,才是站得住的选型。
注意:答辩前把数据库脚本单独导出一份放在项目根目录,评委让你现场重建环境时不至于手忙脚乱。
从那以后我每次交这类项目,都会在演示前一天按清单完整走一遍,并且故意制造两次失败——借空库存、未登录访问,确认提示友好、数据没脏。这套习惯救过我至少两次。希望帮到你。
本文还有配套的精品资源,点击获取