☰
Java图书管理系统开发:Spring Boot+MyBatis实战与并发库存设计
2026/10/21 2:59:30 网站建设 项目流程

1. 系统功能规划与技术选型思路

1.1 先想清楚这系统到底要管什么

Java图书管理系统几乎是每个走Java后端路线的人都会碰到的入门项目,但它绝对不是简单的“增删改查”四个字就能糊弄过去的。我见过不少初学者把代码写完了,问他系统有哪些角色、借书超期怎么处理、图书库存怎么扣减,却支支吾吾答不上来。这说明从一开始就没把业务梳理清楚。

做这个系统之前,至少要明确三件事:谁会用它、他们要做什么、数据之间怎么关联。

先说角色。一个典型的图书管理系统至少要有两类使用者:普通用户和管理员。用户能注册登录、检索图书、查看借阅记录、借书还书;管理员除了这些基础能力,还要能管理图书信息、处理用户账号、查看全站借阅情况、维护图书分类。有些学习项目还会加一个“图书管理员”的中间角色,负责审核借阅申请,但如果是单人练手项目,管理员直接操作就够了,没有必要画蛇添足。

再说功能。我建议把功能分成两个大块:图书管理相关和借阅流程相关。图书管理包括新增图书、编辑信息、删除下架、按分类或关键词检索、库存盘点;借阅流程包括用户发起借书、系统校验库存和用户资格、记录借阅时间和应还时间、归还后更新库存、超期时标记逾期状态。如果再加上简单的统计报表——比如热门图书Top榜、月度借阅量走势——这个系统的完整度就足够了。

最后是数据关系。用户、图书、借阅记录这三张核心表要理清楚:一本图书可以被多个人在不同时间借阅,一个用户可以借多本书,所以用户和图书是多对多关系,需要借阅记录表作为中间表。这个设计如果不提前想明白,后面写SQL会非常痛苦。

1.2 技术栈怎么选,为什么是这套组合

技术选型是这个项目最值得认真对待的环节,因为它直接决定你写的代码能不能找到工作、能不能扩展成生产级系统。我推荐的是Java生态里最常见的一套:Spring Boot + MyBatis + MySQL + JSP(或Thymeleaf)。

为什么不用更复杂的前后端分离?因为这个项目的核心价值是让你把后端逻辑、SQL语句、Session会话管理这些基本功打牢。如果一上来就上Vue + Spring Boot前后端分离,你在拦截器、过滤器、会话保持这些环节上的训练量会大幅减少,而这些恰恰是面试时最容易追问的点。JSP虽然老,但它在同一个工程里能让你直观看到数据从Controller到页面的流转过程。

Spring Boot负责快速搭建工程骨架,MyBatis负责SQL层面的精细控制,MySQL存数据,JSP渲染页面。这套组合的优点是:对新手友好、资料多、部署简单。缺点是前台界面不够现代,但这不是练手项目的重点。

如果你已经有一定基础,可以考虑把JSP换成Thymeleaf,或者做一个纯API后端加一个极简前端页面,但核心逻辑是不变的。下面的实现细节都以Spring Boot + MyBatis + JSP为例来讲,因为这是最稳妥的组合。

提示:除非你是在系统地学习前端框架,否则不要在这个项目里把精力耗费在复杂的界面美化上。系统的重点永远是“某本书有多少库存、某人借了什么书、什么时候该还”这些数据流的正确性。

2. 数据库设计与核心表结构

2.1 三张核心表:图书、用户、借阅记录

数据库设计是整个系统质量的基石,表结构不合理,后面写多少补救代码都别扭。这个系统至少需要下面三张表,我直接给出经过实践验证的建表语句。

图书表需要包含:编号、书名、作者、出版社、ISBN、分类、库存总量、当前可用库存、价格、上架时间、状态。注意“库存总量”和“当前可用库存”是两个字段,前者用来统计馆藏量,后者在每次借书时实时扣减、还书时实时回补。这个设计比只用一个“剩余数量”要清楚得多,因为管理员盘点时能看出历史总量。

用户表需要区分角色,用一个字段role来标记是管理员还是普通用户。密码绝对不能明文存储,至少要加盐后做哈希。Java里常见的做法是用BCrypt或者Spring Security自带的加密工具,这个项目里我用简单方式演示,但代码注释里一定要提醒读者生产环境要用BCrypt。

借阅记录表是整个系统的业务枢纽,字段包括:记录ID、用户ID、图书ID、借书时间、应还时间、实际归还时间、状态(借出中/已归还/已逾期)。这里有两个容易被忽视的细节:应还时间通常是借书时间加30天或60天,这个值在借书时就算好存进去,不要在查询时临时算;实际归还时间为空就表示这本书还在外面。每次还书时要把实际归还时间更新掉,同时回补图书表的可用库存。

2.2 建表SQL与索引设计

下面是完整的建表SQL,我用t_前缀区分业务表,避免和数据字典等系统表混淆:

CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4; USE library_db; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT '密码(加盐哈希后存储)', real_name VARCHAR(50) COMMENT '真实姓名', role TINYINT NOT NULL DEFAULT 0 COMMENT '0-普通用户,1-管理员', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常,0-禁用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE t_book ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '图书ID', isbn VARCHAR(20) COMMENT 'ISBN号', book_name VARCHAR(200) NOT NULL COMMENT '书名', author VARCHAR(100) COMMENT '作者', publisher VARCHAR(100) COMMENT '出版社', category VARCHAR(50) COMMENT '分类', total_count INT NOT NULL DEFAULT 1 COMMENT '库存总量', available_count INT NOT NULL DEFAULT 1 COMMENT '当前可用数量', price DECIMAL(10,2) COMMENT '价格', shelf_time DATETIME COMMENT '上架时间', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-在架,0-下架' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表'; CREATE TABLE t_borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID', user_id INT NOT NULL COMMENT '借书用户ID', book_id INT NOT NULL COMMENT '图书ID', borrow_time DATETIME NOT NULL COMMENT '借书时间', due_time DATETIME NOT NULL COMMENT '应还时间', return_time DATETIME COMMENT '实际归还时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-借出中,1-已归还,2-已逾期还书', KEY idx_user_id (user_id), KEY idx_book_id (book_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅记录表'; -- 初始管理员账号,密码为 admin123(演示用) INSERT INTO t_user (username, password, real_name, role) VALUES ('admin', '21232f297a57a5a743894a0e4a801fc3', '管理员', 1);

索引这块要注意:t_borrow_record的user_id和book_id必须建索引,因为借阅记录会越积越多,每次查“某人借了哪些书”和“某本书被谁借走”都要走这两个字段。t_book的book_name如果要支持模糊搜索,数据量大时可以建全文索引,但几千条数据规模下普通LIKE查询就够了。

2.3 一个容易踩坑的设计细节:库存扣减

库存扣减是并发场景下最容易出问题的地方。假设两个人同时看到一本库存只剩1本的图书,同时点借阅,如果不加任何控制,两个请求都可能判断“现在可用库存为1,足够借出”,结果两个人都借成功了,库存变成-1,系统就产生脏数据。

最稳妥的做法是在一条UPDATE语句里完成“判断和扣减”,利用MySQL的行锁机制避免竞态条件:

@Update("UPDATE t_book SET available_count = available_count - 1 " + "WHERE id = #{bookId} AND available_count > 0") int deductStock(@Param("bookId") Integer bookId);

这个写法和“先查再改”的区别在于:数据库的行锁会在UPDATE执行期间锁住这一行,两个并发请求会排队执行,后者会因为available_count > 0条件不满足而更新失败,返回受影响行数为0,程序就知道扣减失败并中断借阅流程。

这是我在带别人做项目时一定会强调的点。别看现在是个练手项目,这个思想放到任何电商、预订系统里都是通用的。做技术的人都应该养成一个习惯:涉及资源竞争的操作,尽量用数据库层面的原子操作来完成,而不是依赖应用层的判断。

3. 后端核心功能实现

3.1 工程结构搭建与依赖配置

搭建工程时我推荐按“Controller → Service → Mapper”三层结构分包,Controller负责参数接收和页面跳转,Service负责业务逻辑,Mapper负责SQL操作。下面是关键目录结构:

src/main/java/com/example/library/ ├── controller/ // 页面控制器 ├── service/ // 业务逻辑接口 ├── service/impl/ // 业务实现类 ├── mapper/ // MyBatis数据访问接口 ├── entity/ // 实体类 └── config/ // 配置类

Maven依赖里最核心的是这几个:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>

配置方面,在application.properties里设置数据源、MyBatis映射文件路径和JSP视图前后缀:

spring.datasource.url=jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=123456 mybatis.mapper-locations=classpath:mapper/*.xml mybatis.type-aliases-package=com.example.library.entity mybatis.configuration.map-underscore-to-camel-case=true spring.mvc.view.prefix=/WEB-INF/jsp/ spring.mvc.view.suffix=.jsp

注意最后一行map-underscore-to-camel-case=true,它会自动把数据库字段available_count映射成实体类的availableCount属性,省去大量手写resultMap的时间。但代价是表字段命名必须用下划线风格,不要混用驼峰。

3.2 实体类与Mapper层:从数据库到对象

实体类直接对应数据库表,不需要多余逻辑:

public class Book { private Integer id; private String isbn; private String bookName; private String author; private String publisher; private String category; private Integer totalCount; private Integer availableCount; private Double price; private Date shelfTime; private Integer status; // 此处省略getter/setter }

Mapper接口定义操作数据库的方法。这里我用注解SQL的方式演示最简单的查询,复杂SQL放到XML文件里:

@Mapper public interface BookMapper { @Select("SELECT * FROM t_book WHERE status = 1 ORDER BY id DESC") List<Book> findAll(); @Select("SELECT * FROM t_book WHERE id = #{id}") Book findById(Integer id); @Select("<script>" + "SELECT * FROM t_book WHERE status = 1 " + "<if test='keyword != null and keyword != \"\"'>" + "AND (book_name LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%') OR isbn LIKE CONCAT('%', #{keyword}, '%'))" + "</if>" + "ORDER BY id DESC" + "</script>") List<Book> searchBooks(String keyword); }

如果SQL较复杂,比如动态多条件查询或联表查询,建议写在XML里。动态SQL的<if>标签非常实用,能让搜索功能支持“关键词+分类+状态”的组合过滤,这是纯JDBC写起来很痛苦的事,也是MyBatis广受欢迎的重要原因。

3.3 Service层的业务编织:借书的完整流程

Service层是业务逻辑的集中地。以“借书”这个操作为例,它不是一个INSERT就完事,而是一个需要多个步骤配合、还要保证数据一致性的流程:

  1. 校验用户是否存在且状态正常
  2. 校验图书是否在架
  3. 尝试扣减图书可用库存,判断是否扣减成功(这一步是并发控制的关键)
  4. 生成借阅记录,借书时间取当前时间,应还时间取借书时间加30天
  5. 如果中间任何一步失败,抛出异常并回滚

Service层方法建议加@Transactional事务注解,保证“扣库存”和“插入借阅记录”要么同时成功,要么同时失败:

@Service public class BorrowServiceImpl implements BorrowService { @Autowired private BookMapper bookMapper; @Autowired private BorrowRecordMapper borrowRecordMapper; @Autowired private UserMapper userMapper; @Override @Transactional(rollbackFor = Exception.class) public boolean borrowBook(Integer userId, Integer bookId) { User user = userMapper.findById(userId); if (user == null || user.getStatus() != 1) { throw new RuntimeException("用户不存在或已被禁用"); } Book book = bookMapper.findById(bookId); if (book == null || book.getStatus() != 1) { throw new RuntimeException("图书不存在或已下架"); } // 关键操作:扣减库存并校验结果 int affected = bookMapper.deductStock(bookId); if (affected == 0) { throw new RuntimeException("库存不足,借阅失败"); } BorrowRecord record = new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setBorrowTime(new Date()); record.setDueTime(DateUtils.addDays(new Date(), 30)); record.setStatus(0); // 借出中 borrowRecordMapper.insert(record); return true; } }

这里有一个很多人不太习惯的知识点:@Transactional默认只回滚RuntimeException和Error,如果抛出的是受检异常(比如直接throw new Exception()),事务不会自动回滚。所以我写rollbackFor = Exception.class,先把话说明白:只要是异常就回滚,别在这种细节上栽跟头。

3.4 Controller层与页面跳转

Controller的职责是接收请求、调用Service、决定返回哪个页面。对于JSP项目,Controller里返回的是视图名称:

@Controller @RequestMapping("/book") public class BookController { @Autowired private BookService bookService; @Autowired private BorrowService borrowService; @GetMapping("/list") public String list(@RequestParam(defaultValue = "") String keyword, Model model) { List<Book> books = bookService.searchBooks(keyword); model.addAttribute("books", books); return "book/list"; } @PostMapping("/borrow") public String borrow(@RequestParam Integer bookId, HttpSession session) { // 从Session中获取当前登录用户的信息 User loginUser = (User) session.getAttribute("loginUser"); try { borrowService.borrowBook(loginUser.getId(), bookId); return "redirect:/book/list?success=1"; } catch (RuntimeException e) { return "redirect:/book/list?error=" + URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8); } } }

Controller里一个值得注意的点是:借书成功后用redirect:重定向而不是直接返回页面模板。这是PRG模式(Post/Redirect/Get)的标准做法,可以防止用户手动刷新浏览器时重复提交借书表单,避免产生重复借阅记录。

参数校验也不要全部依赖前端。比如bookId为空或非法时,Controller层要能兜住,不能直接让异常打到页面上。我的习惯是加一层简单的非空判断,或者在Service里统一处理并抛出带提示信息的业务异常。

3.5 登录拦截器:保护需要权限的页面

图书管理系统里,未登录用户不应该能访问借书、还书、图书管理这些接口。这个功能用Spring MVC的拦截器(HandlerInterceptor)来实现最合适:

@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } // 如果访问的是管理员接口,还需校验角色 String uri = request.getRequestURI(); if (uri.contains("/admin/") && loginUser.getRole() != 1) { response.sendError(403); return false; } return true; } }

然后在WebMvcConfigurer里配置拦截路径:

@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/css/**", "/js/**", "/images/**"); } }

这里要特别提醒:静态资源的放行别漏了,否则CSS和JS全被拦下来,页面会变得非常难看,而且排错时很难想到是这个原因。

4. 图书检索、借阅与归还的前端实现

4.1 JSP页面布局与数据展示

前端页面的核心任务不是炫酷,而是清晰展示数据。图书列表页是用户接触最多的页面,常规的表格展示加搜索框就够了:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>图书列表</title> <link rel="stylesheet" href="/css/main.css"> </head> <body> <h2>图书管理系统 - 图书列表</h2> <form action="/book/list" method="get"> <input type="text" name="keyword" placeholder="输入书名/作者/ISBN" value="${param.keyword}"> <button type="submit">搜索</button> </form> <table border="1" cellpadding="6" cellspacing="0"> <tr> <th>书名</th> <th>作者</th> <th>出版社</th> <th>ISBN</th> <th>总库存</th> <th>可借数量</th> <th>操作</th> </tr> <c:forEach items="${books}" var="book"> <tr> <td>${book.bookName}</td> <td>${book.author}</td> <td>${book.publisher}</td> <td>${book.isbn}</td> <td>${book.totalCount}</td> <td> <c:choose> <c:when test="${book.availableCount > 0}"> <span class="stock-ok">${book.availableCount}</span> </c:when> <c:otherwise> <span class="stock-zero">已借完</span> </c:otherwise> </c:choose> </td> <td> <c:choose> <c:when test="${book.availableCount > 0}"> <a href="/book/borrow?bookId=${book.id}" onclick="return confirm('确认借阅《${book.bookName}》?')">借阅</a> </c:when> <c:otherwise> 不可借 </c:otherwise> </c:choose> </td> </tr> </c:forEach> </table> <c:if test="${empty books}"> <p>没有找到匹配的图书</p> </c:if> </body> </html>

JSTL的<c:forEach>和<c:choose>非常好用,前者循环数据,后者做条件渲染。注意${param.keyword}是从HTTP请求参数中直接取值,配合表达式语言(EL)可以省掉大量Java代码。

4.2 借阅记录页:用户视角的“我的借阅”

“我的借阅”页面核心是向用户展示当前借了哪些书、什么时候到期、是否超期。这里我建议在查询时就计算超期状态,而不是在页面里临时判断,减少前端代码负担:

public List<BorrowRecordVO> listRecordsByUserId(Integer userId) { List<BorrowRecord> records = borrowRecordMapper.findByUserId(userId); return records.stream().map(record -> { BorrowRecordVO vo = new BorrowRecordVO(); BeanUtils.copyProperties(record, vo); // 如果还没归还且已超过应还日期,标记为“已超期” if (record.getReturnTime() == null && new Date().after(record.getDueTime())) { vo.setOverdue(true); } return vo; }).collect(Collectors.toList()); }

类似这种业务状态计算,放到Service层完成后渲染给页面,是更符合三层架构的做法。页面拿到已经判好的overdue字段,用一句话就能展示状态。这里也是V(View)保持简单、C和S承担逻辑的体现。

4.3 还书功能与逾期处理

还书的核心操作是更新借阅记录、回补库存。归还时如果日期已经超过应还时间,还要把记录状态标记为“已逾期还书”,便于以后统计罚款或信用分:

@Override @Transactional(rollbackFor = Exception.class) public boolean returnBook(Integer recordId) { BorrowRecord record = borrowRecordMapper.findById(recordId); if (record == null || record.getReturnTime() != null) { throw new RuntimeException("借阅记录不存在或已归还"); } Date now = new Date(); Integer newStatus = now.after(record.getDueTime()) ? 2 : 1; // 2-逾期还书, 1-正常归还 borrowRecordMapper.updateReturn(recordId, now, newStatus); // 回补库存,关键操作:只能执行一次 bookMapper.increaseStock(record.getBookId()); return true; }

和扣库存一样,回补库存也有“只能执行一次”的要求。否则同一条记录被用户重复点击还书,库存就会被反复叠加。

4.4 前端提交方式的选择:链接还是表单

上面的图书列表页,我示例中使用了GET链接跳转账务操作,这在真实项目中其实并不推荐。因为GET请求会被浏览器缓存、会被爬虫抓取,还可能被预加载触发,这些都有可能造成重复操作。更好的方案是使用一个小的POST表单或者AJAX请求。

我在生产项目中更倾向用一个简单的Post表单:

<form action="/book/borrow" method="post" style="display:inline;"> <input type="hidden" name="bookId" value="${book.id}"> <button type="submit" onclick="return confirm('确认借阅《${book.bookName}》?')">借阅</button> </form>

这个改动的成本极低,但能显著降低误操作风险。

5. 管理员模块:图书管理与统计分析

5.1 图书新增、编辑与上下架

管理的本质就是让管理员能通过界面完成数据维护,而不需要直接操作数据库。图书新增的表单要校验必填字段和数值合法性,ISBN等长字符串字段要限制长度,避免脏数据入库。Controller侧建议加@Valid注解配合实体类上的@NotBlank、@NotNull等约束,这种方式比在Controller里写一长串if判断要优雅得多:

public class BookForm { @NotBlank(message = "书名不能为空") private String bookName; @NotBlank(message = "作者不能为空") private String author; @NotNull(message = "库存不能为空") @Min(value = 1, message = "库存至少为1") private Integer totalCount; // 其它字段省略 }

编辑时要注意把原值回显到表单,否则管理员保存后会覆盖掉没展示的字段。下架操作不是物理删除,而是把status改成0,这样历史借阅记录还能关联到图书信息。物理删除会破坏数据完整性,千万别干。

5.2 借阅排行与统计报表

统计功能不强求,但作为一个加分项很出彩。最简单的热门图书排行SQL如下:

SELECT b.book_name, COUNT(r.id) AS borrow_count FROM t_borrow_record r LEFT JOIN t_book b ON r.book_id = b.id GROUP BY r.book_id, b.book_name ORDER BY borrow_count DESC LIMIT 10;

这里用LEFT JOIN而不是INNER JOIN,是为了防止借阅记录存在但图书被下架时统计结果缺失。在Mapper里写一条这样的查询语句,Service层封装后传到页面上,一张简单的条形图或表格就能让系统显得完整不少。如果做的更细致,可以按月分组统计借阅量,查看图书借阅趋势。

5.3 用户管理:禁用与角色控制

用户管理对于图书管理系统来说,核心操作就是查看注册用户列表、禁用违规账号、调整角色。前端注册页面通常不让用户自己选择“管理员”角色,要有硬编码限制。我的建议是注册接口里把角色写死为普通用户,管理员只能在后台调整。否则注册接口暴露出来,任何人都能注册成管理员,系统就完全失去安全性。

禁用用户的逻辑要考虑边界:如果某用户当前有未归还的借阅记录,应该允许管理员禁用账号,但是应该限制该用户再登录或者再借书。判断的逻辑放在登录和借书入口处即可,而不是在禁用层面做强制限制。

6. 常见问题与排查技巧实录

6.1 数据库连接失败问题

最常见的报错是Communications link failure或Access denied for user。前者请检查MySQL服务是否启动、连接URL里的IP和端口是否正确、serverTimezone是否配置;后者检查用户名密码。还有一个容易忽略的点:URL里的characterEncoding=utf8不要手滑写成utf-8,MySQL驱动会直接拒绝。

另外MySQL 8.x的驱动类名是com.mysql.cj.jdbc.Driver,老一点的文章里写的com.mysql.jdbc.Driver在驱动8.0后已经被废弃,虽然Spring Boot自动配置通常能兼容,但在底层要心里有数。

6.2 修改数据库字段后JSP页面仍然显示旧数据

这种情况八成是浏览器缓存了JSP页面或静态资源。JSP虽然是服务器端渲染,但很多浏览器会对部分响应做缓存。在调试时建议开浏览器的无痕模式,或者在Controller返回的Model里加一个时间戳参数强制刷新页面资源。

更彻底的检查方式是看后端日志:Controller是否确实执行了、查询结果是否包含新数据。用System.out.println或@Slf4j打印关键数据,比在浏览器里反复刷新好排查得多。

6.3 中文乱码问题

中文乱码是老生常谈,重复出现的原因基本就三类:数据库建表没有用utf8mb4、JDBC连接URL缺少characterEncoding=utf8、JSP页面本身没有声明UTF-8。记得三处同时改:

# 数据库连接 spring.datasource.url=jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=utf8 # JSP页面头 <%@ page contentType="text/html;charset=UTF-8" language="java" %>

如果你做了上面的配置还乱码,可以看看是不是把字符集指定在了pageEncoding之外又额外加了一遍,配置冲突有时也会引起意外行为。

6.4 拦截器导致登录页面循环重定向

这是一个特别容易卡住新手的问题:配置了拦截器后,访问登录页面本身也被拦截,然后登录页尝试重定向到登录页,形成无限循环。

排查的关键是检查你的拦截器配置是否正确排除了/login路径。上面第四节里的excludePathPatterns已经写了,但很多人会漏掉静态资源路径,或者在拦截器里写response.sendRedirect("/login")时没有加request.getContextPath(),部署到非根路径下就会404或死循环。

6.5 库存扣成负数但业务还在继续

如果你发现借阅记录有异常数据,比如库存变成-1但借阅记录正常插入,说明你用的是“先查后改”而不是上文提到的条件更新。可以临时写一条SQL排查:

SELECT b.book_name, b.available_count, COUNT(r.id) AS borrowed_count FROM t_book b LEFT JOIN t_borrow_record r ON b.id = r.book_id AND r.return_time IS NULL GROUP BY b.id, b.book_name, b.available_count HAVING b.available_count < 0 OR b.available_count < 0;

如果有异常数据,先手动修正库存,再修改Mapper的扣减方法,加上available_count > 0条件。这个问题的根因是并发或重复提交,而不是SQL写错,所以要从前端禁用重复点击、后端原子操作两个方向同时封锁。

6.6 Session失效与用户状态不同步

当管理员禁用某个用户后,该用户已经在浏览器里保存了登录Session,他依然可以用旧Session访问接口。解决思路有两种:一是每次请求时都从数据库重新读取一次用户状态,而不是只信Session;二是引入Redis保存用户会话,一旦禁用立刻删除对应会话。

对练手项目来说,第一种方案成本最低,在拦截器里把用户信息从Session取出后,再查一次数据库验证状态即可。虽然会多一条SQL查询,但在这个量级下完全可以接受,换来的是状态一致性。

7. 一些更完善的扩展方向

基础功能跑通之后,如果还有余力,我建议按下面的优先级做扩展:

下一步最值得做的是分页功能。图书数量一旦超过百条,一次性列出所有记录就会明显变慢,页面也难用。用MyBatis Plus自带的Page对象或者手写LIMIT分页都可以,关键是记录当前页和总页数。

然后是密码加密。项目里如果用明文存储密码,尽快换成BCrypt加盐哈希。加一个spring-security-crypto依赖,用BCryptPasswordEncoder的encode和matches即可完成替换。

再往后可以做预约借书(图书被借完时用户可以排队等待)、邮件通知(还书日提醒)、二维码书签管理。这些功能每个都能单独展开,技术上也不算难,但商业价值很高——它们才是真正让图书系统“像产品”的点。从练手项目的角度看,系统做到能处理并发库存、具备权限控制、懂事务回滚,就已经超过大多数同阶段作品了。

最后再分享一个我带人做项目时的习惯:每完成一个模块,立刻用一个表格记录模块名称、功能点、涉及的表、核心方法和可能的风险点。项目做完后这份记录就是你面试时的复习提纲,比抱着几百行代码翻来覆去高效太多。很多人上机面试时卡壳,不是代码不会写,而是说不清楚自己系统每个功能入口到数据库之间到底走过了哪些逻辑。做这个记录的过程中你会发现自己遗漏了多少细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询