
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-禁用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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-下架 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-jasper/artifactId /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency配置方面在application.properties里设置数据源、MyBatis映射文件路径和JSP视图前后缀spring.datasource.urljdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.password123456 mybatis.mapper-locationsclasspath:mapper/*.xml mybatis.type-aliases-packagecom.example.library.entity mybatis.configuration.map-underscore-to-camel-casetrue spring.mvc.view.prefix/WEB-INF/jsp/ spring.mvc.view.suffix.jsp注意最后一行map-underscore-to-camel-casetrue它会自动把数据库字段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) ListBook findAll(); Select(SELECT * FROM t_book WHERE id #{id}) Book findById(Integer id); Select(script SELECT * FROM t_book WHERE status 1 if testkeyword ! 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) ListBook searchBooks(String keyword); }如果SQL较复杂比如动态多条件查询或联表查询建议写在XML里。动态SQL的if标签非常实用能让搜索功能支持“关键词分类状态”的组合过滤这是纯JDBC写起来很痛苦的事也是MyBatis广受欢迎的重要原因。3.3 Service层的业务编织借书的完整流程Service层是业务逻辑的集中地。以“借书”这个操作为例它不是一个INSERT就完事而是一个需要多个步骤配合、还要保证数据一致性的流程校验用户是否存在且状态正常校验图书是否在架尝试扣减图书可用库存判断是否扣减成功这一步是并发控制的关键生成借阅记录借书时间取当前时间应还时间取借书时间加30天如果中间任何一步失败抛出异常并回滚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) { ListBook 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?success1; } 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 contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html head title图书列表/title link relstylesheet href/css/main.css /head body h2图书管理系统 - 图书列表/h2 form action/book/list methodget input typetext namekeyword placeholder输入书名/作者/ISBN value${param.keyword} button typesubmit搜索/button /form table border1 cellpadding6 cellspacing0 tr th书名/th th作者/th th出版社/th thISBN/th th总库存/th th可借数量/th th操作/th /tr c:forEach items${books} varbook 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 classstock-ok${book.availableCount}/span /c:when c:otherwise span classstock-zero已借完/span /c:otherwise /c:choose /td td c:choose c:when test${book.availableCount 0} a href/book/borrow?bookId${book.id} onclickreturn 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 /htmlJSTL的c:forEach和c:choose非常好用前者循环数据后者做条件渲染。注意${param.keyword}是从HTTP请求参数中直接取值配合表达式语言EL可以省掉大量Java代码。4.2 借阅记录页用户视角的“我的借阅”“我的借阅”页面核心是向用户展示当前借了哪些书、什么时候到期、是否超期。这里我建议在查询时就计算超期状态而不是在页面里临时判断减少前端代码负担:public ListBorrowRecordVO listRecordsByUserId(Integer userId) { ListBorrowRecord 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字段用一句话就能展示状态。这里也是VView保持简单、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 methodpost styledisplay:inline; input typehidden namebookId value${book.id} button typesubmit onclickreturn 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里的characterEncodingutf8不要手滑写成utf-8MySQL驱动会直接拒绝。另外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缺少characterEncodingutf8、JSP页面本身没有声明UTF-8。记得三处同时改# 数据库连接 spring.datasource.urljdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingutf8 # JSP页面头 % page contentTypetext/html;charsetUTF-8 languagejava %如果你做了上面的配置还乱码可以看看是不是把字符集指定在了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即可完成替换。再往后可以做预约借书图书被借完时用户可以排队等待、邮件通知还书日提醒、二维码书签管理。这些功能每个都能单独展开技术上也不算难但商业价值很高——它们才是真正让图书系统“像产品”的点。从练手项目的角度看系统做到能处理并发库存、具备权限控制、懂事务回滚就已经超过大多数同阶段作品了。最后再分享一个我带人做项目时的习惯每完成一个模块立刻用一个表格记录模块名称、功能点、涉及的表、核心方法和可能的风险点。项目做完后这份记录就是你面试时的复习提纲比抱着几百行代码翻来覆去高效太多。很多人上机面试时卡壳不是代码不会写而是说不清楚自己系统每个功能入口到数据库之间到底走过了哪些逻辑。做这个记录的过程中你会发现自己遗漏了多少细节。