简介:一套基于Spring Boot的图书管理系统毕业设计源码,面向计算机相关专业毕业生与Java Web入门开发者,用于完成课程设计、毕业设计或理解企业级后端项目的完整实现流程;源码涵盖用户登录、图书查询、借阅管理、用户管理等核心模块,并体现前后端分离、RESTful API设计、Spring Data JPA持久化、Spring Security安全控制等关键知识点,同时包含统一异常处理与日志记录思路。资源包共2017个文件,压缩包大小约119.96MB,其中1692个Markdown文档为项目说明与开发笔记,298个JavaScript文件对应前端交互逻辑,另有JSON配置文件与文本文件辅助项目运行。目录结构清晰,适合按模块逐步阅读源码和文档,目前已有327人学习下载。通过研读这套源码,可掌握从环境部署、业务开发到单元测试、集成测试、日志记录与服务器部署的完整链路,并可直接作为毕业设计项目的基础进行二次开发与扩展。
1. 图书管理系统源码:为什么人人都能跑通,上线却总翻车
Spring Boot 图书管理系统大概是国内开发者接触最多的一类源码,网上一搜一大把,骨架也都差不多:一张图书表、一张用户表、几张关联表,再加一堆增删改查接口。可讽刺的是,很多人下载源码后第一步就卡住,要么数据库连不上,要么中文乱码,要么分页数据对不上——真正能一口气跑通并敢说自己“看懂”的人,比想象中少得多。
这篇文章不打算带你逐行念代码,而是按一套我实际落地过的组织方式,把图书管理系统源码拆成四层:数据结构、接口层、借阅归还流程、前端联动。先讲清楚每一层为什么这么设计,再把关键代码和参数摆出来,最后给出五个真正常见的翻车点及修复方法。无论你是想拿这套源码做课程设计、二次开发,还是想理解 Spring Boot 项目该如何分层,按这条路径走完,你收获的是一份能改、能调、能部署的源码,而不是一个跑起来就完事的黑匣子。
2. 先把表结构和工程骨架立住:数据模型决定后面是省心还是受罪
2.1 技术选型:为什么图书管理源码普遍选 MyBatis-Plus
图书管理系统源码在网上最常见的搭配是 Spring Boot + MyBatis-Plus + Thymeleaf,少数用 JPA 或前后端分离。我的经验是:做这类管理后台,MyBatis-Plus 是性价比最高的选择。原因很直接:图书管理系统的核心操作是单表 CRUD、条件分页、简单的关联查询,而这些恰好是 MyBatis-Plus 的“舒适区”。
对比之下,JPA 的自动建表和 Hibernate 缓存虽然省事,但一旦涉及自定义 SQL 和复杂分页,生成的 SQL 往往不是你预期的那一条;原生 MyBatis 灵活,但每个表都要写一套 XML 和 ResultMap,工作量翻倍。MyBatis-Plus 的 BaseMapper 直接用 LambdaQueryWrapper 做条件构造,查询条件从 Java 代码生成,比字符串拼接安全得多。
选择 MyBatis-Plus 还有一层考虑:这套源码大概率会被新手拿去二次开发。MyBatis-Plus 的 API 命名直观,比如selectPage、lambdaQuery、updateById,看到方法名就知道它在干什么。对于图书管理这种业务逻辑不深、但表数量不少的系统,写入门槛低,后期维护也轻松。
2.2 四张表的设计与 SQL 脚本
在设计图书管理系统源码时,我的建议是不要一上来就堆表,而是先分清“核心表”和“辅助表”。核心表是图书和借阅记录,辅助表是用户和分类。下面这套表结构是我常用的,涵盖登录、图书管理、借书还书三条主线。
-- 用户表 CREATE TABLE `sys_user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL COMMENT '加密后的密码', `real_name` VARCHAR(50) DEFAULT NULL, `role` VARCHAR(20) NOT NULL DEFAULT 'USER', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_role` (`role`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户'; -- 图书分类表 CREATE TABLE `book_category` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL UNIQUE, `sort` INT DEFAULT 0 COMMENT '排序字段,越小越靠前' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书分类'; -- 图书表 CREATE TABLE `book` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `category_id` BIGINT NOT NULL, `book_name` VARCHAR(200) NOT NULL, `author` VARCHAR(100) DEFAULT NULL, `publisher` VARCHAR(100) DEFAULT NULL, `isbn` VARCHAR(20) DEFAULT NULL, `stock` INT NOT NULL DEFAULT 0 COMMENT '库存数量', `borrowed_count` INT NOT NULL DEFAULT 0 COMMENT '已借出数量', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_category` (`category_id`), KEY `idx_book_name` (`book_name`), CONSTRAINT `fk_book_category` FOREIGN KEY (`category_id`) REFERENCES `book_category` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书信息表'; -- 借阅记录表 CREATE TABLE `borrow_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `book_id` BIGINT NOT NULL, `borrow_time` DATETIME NOT NULL COMMENT '借出时间', `expected_return_time` DATETIME NOT NULL COMMENT '应还时间', `actual_return_time` DATETIME DEFAULT NULL COMMENT '实际归还时间', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0借出中 1已归还 2超期未还', KEY `idx_user` (`user_id`), KEY `idx_book` (`book_id`), CONSTRAINT `fk_borrow_user` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`), CONSTRAINT `fk_borrow_book` FOREIGN KEY (`book_id`) REFERENCES `book` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅记录';这套设计里有几个容易被忽略的参数。第一,字符集必须统一用utf8mb4,原因后面避坑章节会展开。第二,book表的stock和borrowed_count是分开的两个字段,stock表示物理库存,borrowed_count表示借出的数量,二者相加才是初始入库量。第三,borrow_record的status用了TINYINT,0 借出中、1 已归还、2 超期未还,这个状态位是后续统计和查询的关键索引。
2.3 工程目录与依赖 pom 示例
表结构定好之后,工程骨架就顺理成章了。我通常按功能分包,而不是按层分包,原因是图书管理系统的功能模块边界清晰,按业务分包在后期加功能时,新增代码的位置更可预期。
src/main/java/com/example/library ├── LibraryApplication.java ├── common // 统一返回体、分页封装、异常处理 ├── config // 拦截器、跨域、MyBatis-Plus 分页插件 ├── controller // BookController、BorrowController、UserController ├── entity // Book、SysUser、BorrowRecord、BookCategory ├── mapper // 各实体对应的 Mapper 接口与 XML ├── service // 业务接口与实现 └── interceptor // 登录拦截器pom.xml 的依赖不需要太多,核心是 Web、MyBatis-Plus、MySQL 驱动、Lombok 和 Thymeleaf。版本号建议直接使用 Spring Initializr 生成的版本组合,不要自己拼,否则容易踩依赖冲突。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <!-- 版本号以你本地项目依赖管理为准 --> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里有一个细节值得留意:mysql-connector-j是高版本 MySQL 驱动包的新坐标,如果你的本地库还在用mysql-connector-java,可以沿用,但驱动类名必须保持一致。这个坐标不一致是新手最容易遇到的启动报错来源之一。
2.4 配置源:application.yml 里的三处环境变量迷思
图书管理系统源码的配置基本都集中在application.yml,但很多下载来的源码把这套配置写死,换台机器就启动失败。我一般会把配置拆成“必改项”和“可选项”,并在注释里写清楚各自的作用。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这三处是重灾区。第一,JDBC URL 里的useUnicode=true&characterEncoding=utf8和serverTimezone=Asia/Shanghai缺一不可,前者管中文不乱码,后者管时间不错乱。第二,map-underscore-to-camel-case这个配置决定了数据库字段book_name能否自动映射到实体属性bookName,不设置为 true 的话,你需要在实体类里手写@TableField注解兜底。第三,逻辑删除配置很容易被忽略,如果图书表有deleted字段却不做逻辑删除,删除接口执行后数据会真消失,这种事故在演示系统里屡见不鲜。
3. 从实体到接口:图书增删改查源码怎么组织不拧巴
3.1 实体与统一返回体
实体类设计应当和表结构一一对应,用 Lombok 减少样板代码。图书实体是核心,我把分类名称冗余进来,这样列表页显示时不用每次关联分类表。
@Data @TableName("book") public class Book { @TableId(type = IdType.AUTO) private Long id; private Long categoryId; private String bookName; private String author; private String publisher; private String isbn; private Integer stock; private Integer borrowedCount; private LocalDateTime createTime; @TableField(exist = false) private String categoryName; }@TableField(exist = false)这个注解是关键,它告诉 MyBatis-Plus 这个字段不是数据库里的列,避免插入和更新时把这个临时字段也拼进 SQL。很多新手踩过这个坑:实体里加了一个categoryName用于展示,结果 MyBatis-Plus 把它当成真实列,执行 insert 时报“未知列 categoryName”。
统一返回体是前后端联调的基础。这套源码常用的格式是code / message / data三元组,接口层的所有返回都走这一个包装,前端拿到后先判code再取data,逻辑统一。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> result = new Result<>(); result.setCode(0); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }我把code语义定为 0 表示成功、非 0 表示失败,而不是 HTTP 状态码。原因在于业务错误和系统错误需要区分,比如“库存不足”是业务错误,HTTP 状态码仍然是 200,但业务 code 可以定为 4001。前端拿着code判断业务逻辑,拿着 HTTP 状态码判断网络层问题,两层各司其职。
3.2 Mapper 与 Service:条件构造器 vs 手写 XML
Mapper 层我用 MyBatis-Plus 的 BaseMapper 覆盖大部分单表操作,只有复杂统计才手写 XML。图书分页查询就是典型场景,搜索条件包含书名模糊查询、分类筛选、库存状态,用 LambdaQueryWrapper 组装条件比拼 SQL 清晰得多。
@Service public class BookServiceImpl implements BookService { @Autowired private BookMapper bookMapper; @Override public PageResult<Book> pageBooks(int pageNum, int pageSize, String keyword, Long categoryId, Integer stockState) { Page<Book> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Book> wrapper = Wrappers.lambdaQuery(); if (StringUtils.hasText(keyword)) { wrapper.and(w -> w .like(Book::getBookName, keyword) .or().like(Book::getAuthor, keyword)); } if (categoryId != null) { wrapper.eq(Book::getCategoryId, categoryId); } if (stockState != null) { // 0 表示无库存,1 表示有库存 wrapper.apply(stockState == 0, "stock - borrowed_count <= {0}", stockState); } wrapper.orderByDesc(Book::getCreateTime); bookMapper.selectPage(page, wrapper); return new PageResult<>(page.getRecords(), page.getTotal()); } }条件构造器有几个参数细节需要讲清楚。keyword用and包裹两个like,是为了防止or把前后条件“炸开”,否则可能查出的不是预期数据。wrapper.apply(stockState == 0, "stock - borrowed_count <= {0}", ...)是把可执行 SQL 片段拼进条件构造器的唯一途径,MyBatis-Plus 的{0}占位符会自动做参数绑定,不会 SQL 注入。selectPage执行后,查询结果和总条数会回填到Page对象里,所以下面可以直接调page.getTotal()。
3.3 Controller 层与 RESTful 接口规范
Controller 是源码的“门面”,接口设计直接决定前端好不好写。我遵循一套简单规则:图书资源用/api/book前缀,方法语义按 GET 查询、POST 新增、PUT 修改、DELETE 删除对应。
@RestController @RequestMapping("/api/book") public class BookController { @Autowired private BookService bookService; @GetMapping("/page") public Result<PageResult<Book>> page(@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String keyword, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) Integer stockState) { return Result.ok(bookService.pageBooks(pageNum, pageSize, keyword, categoryId, stockState)); } @PostMapping public Result<Void> addBook(@RequestBody Book book) { bookService.addBook(book); return Result.ok(null); } @PutMapping("/{id}") public Result<Void> updateBook(@PathVariable Long id, @RequestBody Book book) { book.setId(id); bookService.updateBook(book); return Result.ok(null); } @DeleteMapping("/{id}") public Result<Void> deleteBook(@PathVariable Long id) { bookService.deleteBook(id); return Result.ok(null); } }@RequestParam(required = false)的参数比较适合查询场景,前端不传该参数时后端不会 400。@RequestBody Book book让前端直接传 JSON 对象,新增和修改共用同一个实体模型。注意updateBook里手动setId的动作,因为前端可能把 id 放在 URL 上而不是 body 里,以路径参数为唯一准则,防止越权修改。
3.4 API 自测:curl 与 Postman 的关键参数
源码写完后,第一步验证不是开页面,而是先用 curl 把接口打一遍。我习惯按“查询 → 新增 → 修改 → 删除”的顺序循环测,确认每个环节返回正常再继续做页面。
curl -X GET 'http://localhost:8080/api/book/page?pageNum=1&pageSize=10&keyword=Java' curl -X POST 'http://localhost:8080/api/book' \ -H 'Content-Type: application/json' \ -d '{"categoryId":1,"bookName":"Spring Boot实战","author":"张三","publisher":"某出版社","isbn":"978-7-000-00000-0","stock":5}' curl -X PUT 'http://localhost:8080/api/book/1' \ -H 'Content-Type: application/json' \ -d '{"stock":8}'第一条查询命令是验证分页和搜索组合是否生效;第二条命令注意Content-Type: application/json必须带上,否则 Spring 的@RequestBody解析不到内容直接报 415;第三条命令故意只传stock字段,用于验证 MyBatis-Plus 的updateById是否会忽略 null 字段。默认策略下,传 null 的字段不会参与 update 的 set 子句,这是很实用的特性,但它也意味着你无法通过这种方式把某个字段清空为 null,需要额外注意。
4. 借阅归还与登录权限:业务流程才是这套源码的“肉”
4.1 借书还书的时序与事务边界
图书管理系统的业务核心不在图书 CRUD,而在借书和还书这两个操作。借书时要校验用户存在、图书有库存、当前没有未归还记录,然后扣减库存、新增借阅记录;还书时要更新记录状态、归还日期,并加回库存。这两组操作必须放在同一个事务里,否则中间任何一步失败都会造成数据不一致。
@Service public class BorrowServiceImpl implements BorrowService { @Autowired private BookMapper bookMapper; @Autowired private BorrowRecordMapper borrowRecordMapper; @Override @Transactional(rollbackFor = Exception.class) public void borrowBook(Long userId, Long bookId) { Book book = bookMapper.selectById(bookId); if (book == null) { throw new BusinessException("图书不存在"); } if (book.getStock() - book.getBorrowedCount() <= 0) { throw new BusinessException("图书库存不足"); } int affected = bookMapper.deductStock(bookId); if (affected == 0) { throw new BusinessException("扣减库存失败"); } BorrowRecord record = new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setBorrowTime(LocalDateTime.now()); record.setExpectedReturnTime(LocalDateTime.now().plusDays(30)); record.setStatus(0); borrowRecordMapper.insert(record); } }@Transactional(rollbackFor = Exception.class)里的rollbackFor必须显式指定。Spring 默认只对运行时异常回滚,如果业务代码抛的是受检异常,事务不会回滚,这会导致库存扣了但记录没插入,属于非常隐蔽的数据不一致。
我在这里用了bookMapper.deductStock(bookId)自定义 SQL,而不是先select再update。原因有两个:第一,并发场景下两条线程同时读到库存 5,都执行减 1 的逻辑后可能实际是 5 变 3 而不是 5 变 4;第二,UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0把判断和扣减合成一条原子操作,affected返回 0 就说明库存不够。
4.2 登录校验、拦截器与角色区分
登录功能在图书管理系统源码里有两种常见做法:基于 Session 的传统方案和基于 JWT 的方案。课程设计和内部系统用 Session 更简单,前后端分离或需要跨域调用的场景用 JWT。这套源码我用 Session + 拦截器实现,拦截器的作用是保护除登录页之外的所有页面和接口。
@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getSession().getAttribute("loginUser") != null) { return true; } // 判断是否为页面请求,页面请求返回登录页,接口请求返回 JSON String requestUri = request.getRequestURI(); if (requestUri.startsWith("/api")) { response.setContentType("application/json;charset=utf-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); } else { response.sendRedirect("/login"); } return false; } }这里有个细节:页面请求跳转到/login是浏览器可以处理的 302,但接口请求如果也跳转,前端收到的是 HTML 页面而不是 JSON,解析会直接报错。所以拦截器里必须区分/api前缀的请求和普通页面请求。
角色区分我直接在sys_user表存role字段,值域是ADMIN和USER。管理员的权限是能访问图书管理和借阅配置接口,普通用户只允许操作借书还书和查询自己的记录。拦截器里做角色校验的思路是在WebMvcConfigurer中按路径配置拦截规则。
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/api/user/login", "/css/**", "/js/**", "/img/**"); } }excludePathPatterns这行配置是最容易漏的。很多源码一加拦截器,页面样式全部消失,登录页也刷不出来,原因就是静态资源被拦截了。/css/**、/js/**这些静态资源路径必须显式放行,否则浏览器加载不了任何样式和脚本,页面变成纯 HTML 骨架。
4.3 前端页面:Thymeleaf 把接口串成页面的最小工作集
图书管理系统源码的前端层,我建议用服务端渲染的 Thymeleaf,而不是前后端分离。原因很简单:前端分离意味着要维护两套部署、处理跨域和 Token 刷新,对图书管理这种内部系统来说收益很低。Thymeleaf 直接在 HTML 里写属性标签,把后端的数据模型渲染到页面上,一个应用跑通全部功能。
以图书列表页为例,控制器返回 ModelAndView,模板里用th:each循环渲染表格行。
<table class="table table-striped"> <thead> <tr> <th>书名</th> <th>作者</th> <th>分类</th> <th>库存/借出</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="book : ${page.records}"> <td th:text="${book.bookName}">书名</td> <td th:text="${book.author}">作者</td> <td th:text="${book.categoryName}">分类</td> <td> <span th:text="${book.stock - book.borrowedCount}">可用库存</span> / <span th:text="${book.borrowedCount}">借出数量</span> </td> <td> <a th:href="@{/api/page/borrow?bookId=${book.id}}">借阅</a> </td> </tr> </tbody> </table>th:each是 Thymeleaf 的循环标签,${page.records}取自 Controller 放入 Model 的对象。注意我直接用th:text渲染动态内容,而不用th:utext,因为th:utext不做 HTML 转义,如果书名里包含<script>标签会直接执行,这是服务端渲染页面最容易忽视的 XSS 入口。
4.4 列表页的分页、搜索、状态渲染
分页组件不能只靠后端给数据,页面上还需要一套分页导航。Thymeleaf 里做分页的常见做法是把页码参数拼到 URL 查询参数上,用th:href生成下一页、上一页链接。搜索框提交后也走 GET 请求,把表单字段直接拼为查询参数。
function searchSubmit() { let keyword = document.getElementById("keyword").value; let categoryId = document.getElementById("categoryId").value; let url = "/book/list?pageNum=1&pageSize=10"; if (keyword) url += "&keyword=" + encodeURIComponent(keyword); if (categoryId) url += "&categoryId=" + categoryId; window.location.href = url; }分页状态渲染是个容易翻车的点:用户在第 3 页点击搜索,如果搜索后还停留在第 3 页,但结果只有一页数据,页面就是空白。所以搜索触发时强制pageNum=1,这是我看很多源码都没处理好的细节。
借阅状态渲染用枚举值做映射。borrow_record的status字段在数据库是 0、1、2 三个数字,前端不能直接显示数字。我的做法是在实体里加一个statusDesc字段,通过一个静态映射方法在 Controller 层填充,而不是在页面里写一堆th:if判断。这样模板保持简洁,业务字典的变更也可以集中管理。
5. 图书管理系统源码避坑指南:常见的 5 个翻车点与现场修复
5.1 现象:接口中文乱码,JSON 和数据库全变问号
运行源码后,新增的图书名在数据库里是???,接口返回 JSON 也是乱码。这是我在下载源码时遇到过次数最多的问题,排查优先级相当靠前。
原因有两层:第一层是数据库连接串缺characterEncoding=utf8,MySQL 驱动用的默认字符集不是 UTF-8;第二层是表建的时候用了DEFAULT CHARSET=latin1,哪怕连接串正确,表本身也存不了中文。
解决分两步。先改 JDBC URL,把useUnicode=true&characterEncoding=utf8加进去;如果数据库表已经建好,还要执行ALTER TABLE book CONVERT TO CHARACTER SET utf8mb4。这两个动作缺一不可,只改连接串不动表结构,查询正常但写入后仍是乱码。
5.2 现象:分页总条数与过滤条件对不上
列表页筛选分类后,页码正确但总条数显示的是全表数据量。出现这个问题的源码,大概率是手写了分页 SQL 但把 count 查询写死成了SELECT COUNT(*) FROM book,没有拼接查询条件。
解决方式也分两种情况。用 MyBatis-Plus 的Page和LambdaQueryWrapper时,插件会自动生成带条件的 count 查询,不需要手动处理;如果用的是自定义 XML,必须把 count 语句的 where 条件与查询语句完全对齐。我建议在 XML 里用同一个<sql>片段抽取 where 条件,查询和 count 共用,保证两边条件永不分离。
5.3 现象:借书成功但库存没扣,还书后库存变负数
这种数据不一致的根源是事务边界没画对。常见写法是把“查询库存、判断库存、扣减库存、插入记录”分散在 Controller 和两个 Service 里,@Transactional加在了 Controller 上或者根本没有加。Controller 方法被代理时事务可以生效,但如果内部调用了别的 Service 的方法,事务传播是 REQUIRED 的,会合并到同一个事务,问题往往出在漏写了rollbackFor或抛的是被吞掉的异常。
修复路径是两条。第一,把借书整体操作收拢到一个 Service 方法里,加@Transactional(rollbackFor = Exception.class);第二,把库存扣减改成原子 SQLUPDATE ... WHERE stock > borrowed_count并检查影响行数。后者比前者更重要,因为事务只能保证出错回滚,不能解决并发条件下两个请求同时读到同样库存的问题。
5.4 现象:项目能启动,但前端页面样式全丢,接口 404
启动成功但样式丢失,十有八九是拦截器把静态资源拦了。访问/js/app.js返回的是登录页 HTML,浏览器就把它当文本加载,控制台报 MIME 类型错误。
解决方式是在拦截器配置的excludePathPatterns中显式放行/static/**、/css/**、/js/**、/images/**等路径。如果你用 Thymeleaf 模板,还要确认模板里th:href生成的是/css/style.css这种绝对路径,而不是../css/style.css这种相对路径,否则请求 URL 对不上拦截器配置。
5.5 现象:启动时报端口占用或数据库连接池初始化失败
Port 8080 was already in use是电脑里另一个 Java 进程占着端口。解决办法不是改启动参数,而是先确认占用方,再决定杀进程或者换端口。
lsof -i :8080 kill -9 <PID>数据库连接池失败要看两个细节:spring.datasource.url里数据库名是否存在,spring.datasource.driver-class-name是否与依赖坐标匹配。MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver,MySQL 5 是com.mysql.jdbc.Driver,搞混了就会报ClassNotFoundException。
还有一个隐藏坑:本地 MySQL 的root密码为空时,password参数要写空字符串而不是省略。省略该参数时某些连接池会用系统默认值,可能造成认证失败。
6. 源码熟了之后:从上课 demo 到能上线的两条进阶路
一套图书管理系统源码跑通之后,你要判断的不再是“能不能用”,而是“要不要继续加东西”。我这里给两条我亲身踩过的进阶路线,按投入产出比排序。
第一条路是给查询提速。图书管理系统的数据量通常不大,但分类统计和热书榜这类查询如果反复扫全表,代码会越写越臃肿。我的做法是给book_category增加一个 Redis 缓存,列表接口先查缓存,缓存没有再用 MyBatis-Plus 查询并回填,同时设置 30 分钟过期。切入点很小,代码也只涉及几行注解或一个工具类,但对“列表页每次打开都查一次数据库”这个旧习惯是实打实的改善。
第二条路是日志和权限的规范化。当我接手一个传了三手的图书管理系统源码时,发现一个严重问题:谁在几点改了什么书、借了什么书,完全没有记录。我在服务层加了一个 AOP 切面,记录 Controller 层请求参数、耗时和操作人,并存到一张operation_log表。这个动作并不复杂,但它把源码从“功能演示”拔高到了“可审计的系统”。如果再把 Session 登录换成 JWT,把角色判断从拦截器里抽成注解,这套源码的权限模型就具备了通用性。
我最后要分享的一个教训是:不要小看“最小可用”这三个字。我刚拿到一个 Spring Boot 图书管理系统源码时,第一反应是扩充功能,加了邮件提醒、批量导入导出,结果没把核心的借书还书流程测试彻底,上线第一天就出现库存数据对不上。后来我把源码重新整理,先砍掉所有非核心功能,把借书、还书、图书 CRUD 这三条链路用自动化脚本每天跑一遍,确认无误再往上追加功能。这个习惯我沿用到现在。
如果你正打算用这套源码做点什么,我的建议是从“改一个真实需求”入手,比如加上“今天到期的图书自动提醒”,而不是从“读懂所有代码”入手。带着需求读源码,你会更快看到哪段代码是核心、哪段代码可以删,这样学到的东西才真正长在你身上。希望帮到你。
本文还有配套的精品资源,点击获取