尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
SSM图书管理系统实战:三层架构、借还书事务与库存扣减排错
简介面向Java Web初学者、课程设计与毕业设计人群的SSM图书管理系统项目包可用于综合练习Spring、SpringMVC与MyBatis的分层开发解决从框架整合到图书业务落地缺少完整案例的问题。项目围绕权限登录、图书信息维护、借阅续借归还、邮件通知和答题互动等模块展开体现IoC依赖注入、AOP日志事务、MVC请求调度及MyBatis SQL映射等关键机制模块划分清晰便于理解控制层、业务层与持久层协作。压缩包共3个文件包含1个rar主项目包、1个txt使用手册和1个sql数据库脚本整体约91.12MB可配合MySQL与Tomcat完成环境搭建、数据初始化与功能验证。已有3255人学习适合据此梳理SSM项目结构、对照数据库设计理解业务表关系并在现有代码上进行功能扩展与二次开发。1. 先说清楚这套 SSM 图书管理系统能干什么SSM 图书管理系统最典型的使用场景是课程设计和入门级完整项目练手Spring 管 Bean 和事务、SpringMVC 管请求分发与视图跳转、MyBatis 管 SQL 映射三件套拼起来正好覆盖一个中小型业务系统的全部骨架。它真正解决的问题不是“图书管理”这个业务本身而是给你一条能跑通的完整闭环——登录鉴权、图书增删改查、分类维护、读者管理、借书还书、分页加模糊搜索拿去做二次开发、换皮改字段、写设计文档都够用。适合的人群也很明确学完 JavaWeb、能在 IDEA 里部署一个 war 包、但还没独立写过一个完整三层架构项目的人。如果你已经能熟练用 Spring Boot 写 CRUD这套东西对你来说偏“考古”但用来理解 MyBatis 的 XML 映射和事务边界反而比一堆注解更直观。后面按“环境怎么对齐 → 表怎么设计 → 借还书链路怎么写 → 报错怎么查 → 还能怎么改”的顺序拆每一步都是能直接抄的。2. 工程结构与运行环境三层在图书管理系统里各管什么很多人拿到源码第一反应是双击打开、配个 Tomcat、点运行然后卡在 404 或者 500 上耗掉一晚上。真正省时间的做法是先花十分钟看清目录搞清楚哪个文件管哪一层再动手配环境。这一章把这套 SSM 工程的骨架扒开后面所有问题都能对上号。2.1 目录结构为什么 mapper 的 XML 要放在 resources 下典型的单 war 工程长这样注意resources和webapp的分工这是后面一堆“找不到文件”报错的根源library/ ├── pom.xml ├── src/main/java/com/demo/library/ │ ├── controller/ # 只接请求、校验参数、返回结果 │ ├── service/ # 接口 │ │ └── impl/ # 实现类事务注解加在这一层 │ ├── mapper/ # MyBatis 接口方法名要和 XML 的 id 对上 │ ├── entity/ # 和数据库表一一对应的 POJO │ ├── interceptor/ # 登录拦截器 │ └── util/ # 统一返回体、日期工具、MD5 ├── src/main/resources/ │ ├── mapper/ # BookMapper.xml / BorrowMapper.xml 等 │ ├── spring-core.xml # 数据源、SqlSessionFactory、事务管理器 │ ├── spring-mvc.xml # 包扫描、视图解析器、拦截器注册 │ ├── jdbc.properties # 数据库四要素 │ └── log4j.properties # 控制台打 SQL 全靠它 ├── src/main/webapp/ │ ├── WEB-INF/ │ │ ├── web.xml # 编码过滤器、DispatcherServlet、ContextLoaderListener │ │ └── views/ # JSP 页面 │ └── static/ # js、css、图片关键点在于Maven 默认只把src/main/java下的.java编译进classes同目录下的.xml会被忽略。所以 mapper 的 XML 必须放resources/mapper再通过mapperLocations指向它。见过太多人把 XML 和接口放一起本地 IDEA 跑得好好的一打 war 包就Invalid bound statement血泪经验。注意如果你确实想 XML 和接口同目录就必须在pom.xml的buildresources里显式加上**/*.xml的 include否则打包必丢。2.2 三层的职责边界位置写错后面全是补丁这套架构真正的价值不是“分了三层”而是每一层知道自己在哪该停手。下面这张表是我改别人代码时最先看的东西层该做的事不该做的事常见误用Controller接收参数、做非空与格式校验、决定返回 JSON 还是 JSP、组装统一返回体写 SQL、开事务、做库存判断在 Controller 里循环调 Mapper 拼业务Service业务编排、跨表逻辑、事务边界、调用多个 Mapper出现HttpServletRequest、做页面跳转把事务注解加在 Controller 上Mapper单表或多表 SQL、动态条件、结果映射写 if-else 业务分支、调用别的 Service一个方法里塞十张表的 join选型上说一句为什么用 XML 而不是全注解图书管理系统的查询页面基本都带“书名模糊 分类 是否可借”三条件组合用whereif拼动态 SQL 比在注解里写script标签清爽太多改起来不用重新编译整个类。这也是这套老技术栈到现在还有生命力的原因。2.3 环境对齐JDK 8 Tomcat 9 MySQL 8 这一组最省事版本不对齐是新手最大的时间黑洞。最稳的组合是 JDK 8、Tomcat 8.5 或 9、MySQL 5.7 或 8.0、Maven 3.6。重点提醒一句不要用 Tomcat 10它把javax.servlet换成了jakarta.servlet这套基于javax写的工程部署上去直接 500。步骤如下先确认本地工具链# 三个版本必须能看到输出版本号按上面那组对齐 java -version mvn -v mysql --version # 在工程根目录执行先确认能编译过排除依赖问题 mvn clean package -DskipTests编译通过后在 IDEA 里配 TomcatRun → Edit Configurations → → Tomcat Server → LocalDeployment 页签添加library:war explodedApplication context 填/library。启动后访问http://localhost:8080/library/应该看到登录页。如果登录页能出来但样式全丢是 DispatcherServlet 把静态资源也拦了在spring-mvc.xml里补一行!-- 放行静态资源否则 /static/** 会被当成一个 Controller 请求处理 -- mvc:resources mapping/static/** location/static//mapping是浏览器访问路径location是 webapp 下的实际目录两个斜杠都不能少。这一步配好后面调页面才不会被 404 干扰。3. 建库建表三张主表怎么撑起借还业务表设计决定了后面代码能写多干净。图书管理系统看着简单真正容易翻车的地方只有两个一是库存字段和借阅记录不同步二是查询借阅列表时不知道该 join 几张表。把表设计对了这两件事都能在 SQL 层面解决掉一半。3.1 五张表的分工与关键字段表名主键关键字段作用book_infobook_idisbn、name、type_id、total、stock图书档案total是馆藏总量stock是可借数量book_typetype_idtype_name分类字典用于下拉框和分类筛选reader_inforeader_idcard_no、name、max_borrow、status读者档案max_borrow控制最多可借几本borrow_infoborrow_idbook_id、reader_id、borrow_date、due_date、return_date借阅流水return_date为空表示未还adminadmin_idusername、password后台登录账号这里有个设计取舍值得说stock是冗余字段理论上可以用total减去未还记录数算出来。但列表页要显示“可借 3 本”每次去 count 一遍borrow_info会把查询拖慢所以用冗余字段换读性能代价是必须保证借还书时同步更新——这也是第四章事务要重点处理的地方。borrow_info里我没有用status字段表示借还状态而是直接用return_date IS NULL判断好处是不会出现“状态是已还但归还时间还是空”这种脏数据少一个字段就少一类 bug。3.2 建库脚本一次执行完别再手工点下面这段直接丢进 Navicat 或命令行执行包含建库、建表、索引和几条初始数据-- 库和字符集一次定死后面不用再改 CREATE DATABASE IF NOT EXISTS library DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE library; CREATE TABLE book_info ( book_id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, author VARCHAR(50) DEFAULT NULL, press VARCHAR(80) DEFAULT NULL, -- 出版社 type_id INT NOT NULL, price DECIMAL(8,2) DEFAULT 0.00, total INT NOT NULL DEFAULT 1, -- 馆藏总量 stock INT NOT NULL DEFAULT 1, -- 当前可借借出减一归还加一 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_name (name), -- 书名模糊查询走前缀索引 KEY idx_isbn (isbn) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE book_type ( type_id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(30) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE reader_info ( reader_id BIGINT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL, name VARCHAR(30) NOT NULL, phone VARCHAR(20) DEFAULT NULL, max_borrow INT NOT NULL DEFAULT 5, -- 最多同时借几本 status TINYINT NOT NULL DEFAULT 1, -- 1 正常 0 冻结 UNIQUE KEY uk_card_no (card_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE borrow_info ( borrow_id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, reader_id BIGINT NOT NULL, borrow_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, due_date DATETIME NOT NULL, -- 应还日期借出时算好 return_date DATETIME DEFAULT NULL, -- 为空表示未归还 KEY idx_reader (reader_id, return_date), -- 查某人的未还记录 KEY idx_book (book_id, return_date) -- 查某本书的借出情况 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE admin ( admin_id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL, password VARCHAR(64) NOT NULL, -- 存 MD5不要存明文 UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 初始数据够你跑通流程就行 INSERT INTO admin (username, password) VALUES (admin, e10adc3949ba59abbe56e057f20f883e); INSERT INTO book_type (type_name) VALUES (计算机), (文学), (历史); INSERT INTO book_info (isbn, name, author, press, type_id, price, total, stock) VALUES (9787111128069, 算法导论, 某作者, 某出版社, 1, 89.00, 3, 3); INSERT INTO reader_info (card_no, name, phone) VALUES (R2024001, A同学, 13800000000);执行完SHOW TABLES;能看到五张表就算过了。这一段没什么玄学但引擎必须是InnoDB——如果用 MyISAM后面Transactional加了也是白加回滚根本不生效这个坑第五章会细说。3.3 索引与约束三个必须加一个千万别加上面脚本里已经埋了索引解释一下为什么是这几个borrow_info上的两个联合索引都带return_date因为最常用的两条查询是“查这个读者还没还的书”和“查这本书当前被谁借着”把过滤字段放进索引第二列WHERE return_date IS NULL就能走索引。book_info.name上只建了普通索引没建唯一索引。有人觉得书名该唯一实际业务里同名书太正常了不同版次、不同出版社建了唯一键后面插数据一直报Duplicate entry。真正该唯一的是reader_info.card_no和admin.username这两个是业务主键重复了就是数据错误。-- 上线前自查看索引有没有生效 EXPLAIN SELECT * FROM borrow_info WHERE reader_id 1 AND return_date IS NULL;看type列是不是refkey列是不是命中了idx_reader。如果显示ALL说明索引白建了多半是字段类型不匹配比如reader_id传了字符串。4. 借还书链路逐层落地代码与事务边界怎么划借书这个动作看着只有两步实际牵扯四件事校验读者状态和额度、扣库存、插借阅记录、算应还日期。四件事必须在同一个事务里任何一步失败都要整体回退。这一章把这条链路从 Controller 一路写到 Mapper给出能直接抄的代码并解释每个参数为什么这么设。4.1 Controller只做三件事多一件都是坑Controller RequestMapping(/borrow) public class BorrowController { Autowired private BorrowService borrowService; /** * 借书接口 * param readerId 读者主键前端从下拉框带过来 * param bookId 图书主键 */ PostMapping(/lend) ResponseBody public Result lend(RequestParam Long readerId, RequestParam Long bookId) { // 1. 参数兜底校验别指望前端一定传对 if (readerId null || bookId null) { return Result.fail(参数缺失); } // 2. 业务全部交给 ServiceController 不碰任何判断逻辑 try { borrowService.lend(readerId, bookId); return Result.ok(借阅成功); } catch (BizException e) { // 3. 业务异常转成可读提示堆栈打日志但不返回给前端 return Result.fail(e.getMessage()); } } }ResponseBody表示返回 JSON 而不是找 JSP前端用 ajax 调就行。注意catch的是自定义的BizException不是Exception——如果这里 catch 了Exception又没往外抛Service 层的事务会因为异常被吞掉而不回滚这是最隐蔽的一类 bug第五章展开。参数用RequestParam而不是RequestBody是因为这套工程前端是 JSP 表单 jQuery走的是application/x-www-form-urlencoded。如果改成前后端分离传 JSON换成RequestBody加一个 DTO 即可Service 层不用动。4.2 Service事务注解加在哪决定了回滚能不能生效Service public class BorrowServiceImpl implements BorrowService { Autowired private BookMapper bookMapper; Autowired private BorrowMapper borrowMapper; Autowired private ReaderMapper readerMapper; /** * rollbackFor Exception.class 必须写 * 默认只回滚 RuntimeException业务里抛的受检异常不会触发回滚 */ Override Transactional(rollbackFor Exception.class) public void lend(Long readerId, Long bookId) throws BizException { // 1. 读者是否存在且状态正常 Reader reader readerMapper.selectById(readerId); if (reader null || reader.getStatus() ! 1) { throw new BizException(读者不存在或已被冻结); } // 2. 是否超过可借上限 int current borrowMapper.countUnreturned(readerId); if (current reader.getMaxBorrow()) { throw new BizException(已达可借上限 reader.getMaxBorrow() 本); } // 3. 是否已经借了同一本书没还 if (borrowMapper.countSameBook(readerId, bookId) 0) { throw new BizException(这本书还没还不能重复借); } // 4. 扣库存返回受影响行数0 表示库存已被抢空 int affected bookMapper.reduceStock(bookId); if (affected 0) { throw new BizException(库存不足); } // 5. 插借阅记录应还日期默认 30 天后 BorrowInfo record new BorrowInfo(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowDate(new Date()); record.setDueDate(DateUtil.plusDays(new Date(), 30)); borrowMapper.insert(record); // 任何一步抛异常上面扣的库存会被一起回滚 } }几个参数值得说明rollbackFor Exception.class是硬性要求。Spring 默认只在遇到RuntimeException和Error时回滚如果BizException继承的是Exception抛出去事务照样提交库存扣了记录没插数据直接烂掉。reduceStock的返回值一定要判断。很多人写成UPDATE book_info SET stock stock - 1 WHERE book_id ?然后不管返回值直接插记录库存为 0 的时候会扣成负数。加上AND stock 0再判断受影响行数天然就防住了超卖。DateUtil.plusDays是工程里自己写的工具方法用Calendar或LocalDateTime.plusDays实现都行别用new Date(System.currentTimeMillis() 30*24*3600*1000L)这种写法跨时区和夏令时会偏。提示Transactional只在被 Spring 代理的外部调用时生效。如果同一个类里 A 方法直接this.b()事务注解在b上是不起作用的这是自调用失效问题排查时容易看漏。4.3 Mapper条件扣减与动态查询 SQL接口定义保持极简所有 SQL 都在 XML 里public interface BookMapper { // 带条件的原子扣减返回受影响行数 int reduceStock(Param(bookId) Long bookId); // 归还原位 int addStock(Param(bookId) Long bookId); } public interface BorrowMapper { int countUnreturned(Param(readerId) Long readerId); int countSameBook(Param(readerId) Long readerId, Param(bookId) Long bookId); int insert(BorrowInfo record); // 分页 多条件查询借阅列表 ListBorrowVO selectPage(Param(keyword) String keyword, Param(readerId) Long readerId, Param(onlyUnreturned) Boolean onlyUnreturned); }对应的 XMLmapper namespacecom.demo.library.mapper.BookMapper !-- stock 0 是关键写成 stock 0 就会扣成负数 -- update idreduceStock UPDATE book_info SET stock stock - 1 WHERE book_id #{bookId} AND stock gt; 0 /update update idaddStock UPDATE book_info SET stock stock 1 WHERE book_id #{bookId} AND stock lt; total /update /mapper mapper namespacecom.demo.library.mapper.BorrowMapper !-- 查某读者当前未还数量用于额度校验 -- select idcountUnreturned resultTypeint SELECT COUNT(1) FROM borrow_info WHERE reader_id #{readerId} AND return_date IS NULL /select select idcountSameBook resultTypeint SELECT COUNT(1) FROM borrow_info WHERE reader_id #{readerId} AND book_id #{bookId} AND return_date IS NULL /select !-- 借阅列表三表 join条件全部动态拼 -- select idselectPage resultTypecom.demo.library.entity.BorrowVO SELECT b.borrow_id, b.borrow_date, b.due_date, b.return_date, k.name AS bookName, k.isbn, r.name AS readerName, r.card_no FROM borrow_info b JOIN book_info k ON b.book_id k.book_id JOIN reader_info r ON b.reader_id r.reader_id where if testkeyword ! null and keyword ! AND (k.name LIKE CONCAT(%, #{keyword}, %) OR r.name LIKE CONCAT(%, #{keyword}, %)) /if if testreaderId ! null AND b.reader_id #{readerId} /if if testonlyUnreturned ! null and onlyUnreturned AND b.return_date IS NULL /if /where ORDER BY b.borrow_date DESC /select /mappergt;和lt;是 XML 里对的转义直接写会报解析错误这个坑几乎人人踩一次。selectPage里没有写LIMIT因为分页交给 PageHelper 在 Service 里处理// startPage 必须紧挨着目标查询中间不能插别的查询 PageHelper.startPage(pageNum, pageSize); ListBorrowVO list borrowMapper.selectPage(keyword, readerId, onlyUnreturned); PageInfoBorrowVO pageInfo new PageInfo(list);startPage只对紧随其后的第一条查询生效这是 PageHelper 最容易被误用的地方第五章会作为独立的坑展开。还书链路就是反过来先按borrow_id更新return_date加WHERE return_date IS NULL防止重复归还再调addStock加回库存同样放在一个事务里逻辑和借书对称代码不再重复贴。5. 常见问题排查启动 500、乱码、事务不回滚的五条记录这套东西跑不起来九成问题集中在这五个地方。每条我都按“现象 → 原因 → 解决”写方便你对着日志直接定位不用从头读代码。5.1 启动阶段的两个坑坑一部署到 Tomcat 10访问任何页面都是 500日志里ClassNotFoundException: javax.servlet.http.HttpServlet现象是工程编译完全正常一部署就崩日志顶部通常是NoClassDefFoundError或者 Servlet 初始化失败。原因是 Tomcat 从 10.0 开始把 Servlet API 的包名从javax.servlet.*换成了jakarta.servlet.*而这套 SSM 工程的web.xml还是 4.0 之前的javax版本DispatcherServlet加载时找不到父类。解决方式有两个把 Tomcat 换成 8.5 或 9推荐改动最小或者把pom.xml里的javax.servlet-api和 Spring 版本一起升级到支持 Jakarta 的版本——后者牵一发动全身不值得为一个课程设计折腾。坑二启动报Invalid bound statement (not found): com.demo.library.mapper.BookMapper.reduceStock现象是 Tomcat 起来了点借书按钮报 500日志里提示找不到 Mapper 的语句。原因有三种XML 没被打包进classes最常见、XML 的namespace和接口全限定名对不上、XML 里的id和方法名拼错。排查顺序建议这样先看target/classes/mapper/目录下有没有 XML 文件没有就是打包问题在pom.xml加资源过滤build resources resource directorysrc/main/resources/directory includes include**/*.xml/include include**/*.properties/include /includes /resource /resources /build如果 XML 在就核对namespace是否一字不差再核对spring-core.xml里mapperLocations的路径是不是classpath*:mapper/*.xml——注意是classpath*带星号写成classpath:只会加载第一个匹配的 jar。5.2 运行阶段的三个坑坑三表单提交中文变问号页面显示???现象是新增图书时书名输入中文入库后变成问号或者乱码。原因通常是三层里任意一层没对齐web.xml没配编码过滤器、JDBC URL 没带字符集参数、数据库或表的字符集不是utf8mb4。解决要三处一起改缺一处都还会乱!-- web.xml 中必须放在所有 filter 最前面 -- filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mappingforceEncoding一定要设成true它同时管请求和响应只设encoding只管请求体。JDBC URL 里补上useUnicodetruecharacterEncodingutf8。数据库层面执行SHOW CREATE TABLE book_info;确认是utf8mb4MySQL 8 默认已经是5.7 需要手动指定。坑四借书时库存扣了借阅记录没插进去数据对不上这是最要命的一类问题往往还是偶发。原因有四个可能Transactional加在了 Controller 上、异常被catch后没重新抛出、同一个 Service 类内部方法自调用、表引擎是 MyISAM。逐条排查事务注解必须在 Service 实现类的方法上catch里如果要吞异常必须手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()自调用改成注入自身代理或者把方法拆到另一个 Service执行SHOW TABLE STATUS LIKE borrow_info;看Engine列是不是InnoDB。另外别忘了rollbackFor Exception.class默认规则只回滚运行时异常业务里抛的受检异常会让事务照常提交。我一般写完借还书逻辑会故意在插记录后手动throw new RuntimeException()测一次看库存有没有回退——这是最省事的验证方式。坑五分页总数不对或者每页条数乱跳现象是列表页查出 10 条但总数显示 1或者翻到第二页数据重复。原因基本是 PageHelper 的startPage位置放错了。它靠 ThreadLocal 把分页参数挂在当前线程只对紧接着的第一条查询生效// 错误写法中间插了别的查询分页参数被吃掉 PageHelper.startPage(pageNum, pageSize); int total borrowMapper.countAll(); // 分页参数被这条消费了 ListBorrowVO list borrowMapper.selectPage(...); // 正确写法startPage 和目标查询之间不能有任何 Mapper 调用 ListBorrowVO list borrowMapper.selectPage(...); // 先不写 PageHelper.startPage(pageNum, pageSize); // 紧贴 list borrowMapper.selectPage(...);还有一种更隐蔽的查询结果里用了collection做一对多嵌套PageHelper 会把子集合也一起截断导致每页只剩一条主记录。这种情况要么改成两次查询在 Service 里组装要么用PageHelper.startPage(pageNum, pageSize, false)关掉 count 查询自己算总数。6. 进阶用法库存扣减的原子写法和交付前的自检把功能跑通只是及格线这套东西真正能拿去交差还得解决并发和验证两个问题。我一般会在原工程上做两处小改造代码量不大但对“能不能用”的判断影响很大。6.1 库存扣减必须是带条件的 updateUPDATE ... SET stock stock - 1 WHERE book_id ? AND stock 0这一句看似已经防住了超卖实际上是靠数据库的行锁串行化的两个请求同时到第一个拿到行锁扣减成功第二个等锁释放后再判断stock 0此时库存已经变了条件不成立返回 0 行。这个机制成立的前提是WHERE条件里带了stock如果只按主键更新再在 Java 里判断就会出现经典的“先查后改”竞态。-- 验证方式开两个会话手动模拟并发 START TRANSACTION; UPDATE book_info SET stock stock - 1 WHERE book_id 1 AND stock 0; -- 先不 commit另开一个会话执行同一条会阻塞在这里 COMMIT;第二个会话阻塞住说明行锁生效了。如果它立刻返回 1 行那多半是表引擎不对或者隔离级别被改过需要回头看第三章的建表语句。再补一层兜底在 Service 里先查“该读者是否已借同一本书未还”但这个查询在极端并发下也会漏。真正可靠的做法是给borrow_info加一个(reader_id, book_id, return_date)的联合唯一索引——未还时return_date为NULLMySQL 的唯一索引允许多个NULL值所以这个约束挡不住重复。更实用的方案是在应用层用 Redis 分布式锁或者数据库悲观锁SELECT ... FOR UPDATE但对课程设计来说前面那个带条件的 update 已经够用了别过度设计。6.2 交付前跑一遍的三条验证功能写完不等于能交至少要过这三关验证项操作通过标准事务完整性临时在插借阅记录后抛RuntimeException库存回到借出前的值borrow_info无新增边界值把stock改成 0 再点借书提示“库存不足”不产生任何数据变更权限拦截清掉 Cookie 直接访问/book/list跳回登录页不出现 500 或白屏第三条需要拦器配好spring-mvc.xml里注册时记得排除登录接口和静态资源否则会出现“登录页自己被拦跳转到登录页”的死循环mvc:interceptors mvc:interceptor mvc:mapping path/**/ !-- 这三个必须排除否则永远登不进去 -- mvc:exclude-mapping path/login/ mvc:exclude-mapping path/doLogin/ mvc:exclude-mapping path/static/**/ bean classcom.demo.library.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptors从那以后我每次改完借还书相关的代码都会手动制造一次异常跑一遍事务验证不管改动多小——因为库存和流水对不上这种问题往往要等到答辩现场演示时才暴露那时候连日志都来不及看。这套 SSM 图书管理系统的源码结构清晰、改动点集中很适合拿来练这个习惯先把环境按第二章对齐把表按第三章建好再照着第四章的链路走一遍剩下的坑第五章基本都覆盖了。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

滑动窗口的使用(一)

滑动窗口的使用(一)

一、长度最小的子数组 209. 长度最小的子数组 - 力扣&#xff08;LeetCode&#xff09; class Solution { public:int minSubArrayLen(int target, vector<int>& nums) {int n nums.size();int sum 0;int len INT_MAX;int t 0;for(int left 0, right 0;right …

📅 2026/10/30 2:44:50
FileLocator Pro v9.3.3566:免索引全文搜索+压缩包内容扫描+正则布尔,安装包下载

FileLocator Pro v9.3.3566:免索引全文搜索+压缩包内容扫描+正则布尔,安装包下载

电脑里文件越来越多&#xff0c;记不清文件名的情况我遇到过不少。只记得文档里的一句话&#xff0c;却翻不到在哪&#xff0c;很烦。FileLocator Pro 就是解决这个问题的。它不用提前建索引&#xff0c;直结对文件内容做全文搜索。Word、PDF 甚至压缩包里的文字都能扫出来&…

📅 2026/10/30 2:44:50
STM32MP2 Developer Package(stm32mp-openstlinux-6.6-yocto-scarthgap-mpu-v26.06.10) - (2)

STM32MP2 Developer Package(stm32mp-openstlinux-6.6-yocto-scarthgap-mpu-v26.06.10) - (2)

更新证书 阿里云 python3 scripts/signaling_server.py --host 0.0.0.0 --port 9000 cat /etc/nginx/ssl/cam.crtmkdir -p /usr/local/share/ca-certificates/ cp /home/root/cam.crt /usr/local/share/ca-certificates/update-ca-certificatesdeveloper developer#!mkdir Dist…

📅 2026/10/30 2:44:50
MORE NEWS

更多资讯

📰

EMC整改实战:辐射发射、传导发射与静电抗扰度排查思路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

嵌入式开发从裸机到Linux的四阶段工程路线图

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

一周就业信息速递:从采集到发布的完整实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

20+款ESP32在线开发工具盘点:浏览器仿真、烧录与免安装实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

STM32实战进阶:用嵌入式C++补齐外设、总线与数据上云的关键技术解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

ESP-IDF驱动DHT11实战:从时序原理到稳定采集的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬